构建基于Jib与Rome的TDD驱动型多语言微服务本地开发环境


团队内部的多语言(Polyglot)微服务实践已经有一段时间,一个常见的组合是后端使用稳定的JVM语言(如Java/Kotlin)处理核心业务逻辑,而BFF(Backend for Frontend)层则采用Node.js/TypeScript,以其生态和异步IO优势快速响应前端需求。然而,这种组合在开发阶段暴露出的摩擦成本越来越高。本地环境的搭建、测试、构建流程各异,Java侧依赖Maven/Gradle和Dockerfile,TypeScript侧则是一套npm scripts配合Dockerfile。构建镜像缓慢、环境不一致、TDD流程割裂,这些问题直接拖慢了交付速度。

这次复盘的目标就是彻底解决这个问题:打造一个统一、高效、遵循TDD原则的多语言微服务本地开发环境。技术选型的核心诉求是:快、一致、简单

  • Java侧构建: 放弃传统的Dockerfile,它太慢且需要Docker守护进程。我们选择Google的Jib,它可以直接从Maven/Gradle插件将Java应用构建成OCI兼容的镜像,无需Docker守护进程,分层构建机制也极大提升了增量构建的速度。
  • TypeScript侧工具链: 混乱的JS工具链是另一个痛点——ESLint, Prettier, Jest, Babel/TSC各自为政,配置文件繁多。我们决定押注Rome,一个旨在统一前端开发工具链的项目。它集linter, formatter, compiler, bundler, test runner于一身,单一配置文件,性能极高。
  • 服务间通信: 为了解耦服务并保持高性能,我们引入一个轻量级的键值型NoSQL数据库——Redis,作为服务间的通信总线或共享缓存。
  • 开发方法论: TDD(测试驱动开发)必须是整个流程的基石,确保代码质量和快速反馈。

最终的开发工作流应该像这样:工程师在修改任一服务后,只需一个命令就能运行所有相关的单元测试和集成测试,并在几秒钟内生成最新的容器镜像,再通过一个命令启动整个本地微服务集群。

项目结构规划

一个清晰的项目结构是保证一致性的前提。我们规划如下:

polyglot-tdd-env/
├── service-java-user/            # Java用户服务
│   ├── pom.xml
│   └── src/
│       ├── main/
│       │   └── java/
│       └── test/
│           └── java/
├── service-ts-bff/               # TypeScript BFF服务
│   ├── rome.json
│   ├── package.json
│   └── src/
│       ├── index.ts
│       └── index.test.ts
├── docker-compose.yml            # 本地环境编排
└── Makefile                      # 统一任务入口

Makefile是整个工作流的指挥中心,它将屏蔽底层不同语言的构建细节,为开发者提供统一的接口,如make test, make build, make up, make down

架构与数据流

我们将实现一个极简的场景:BFF服务接收一个用户ID,通过Redis向Java用户服务请求用户信息。

sequenceDiagram
    participant Client
    participant BFF_Service (TypeScript)
    participant Redis
    participant User_Service (Java)

    Client->>BFF_Service (TypeScript): GET /user/123
    BFF_Service (TypeScript)->>Redis: PUBLISH user:query:123
    Note right of BFF_Service (TypeScript): 发布查询请求
    
    User_Service (Java)-->>Redis: SUBSCRIBE user:query:*
    User_Service (Java)->>Redis: GET user:data:123
    Note right of User_Service (Java): 模拟查询数据库
    Redis-->>User_Service (Java): (user data or nil)
    User_Service (Java)->>Redis: PUBLISH user:response:123 (user data)
    
    BFF_Service (TypeScript)-->>Redis: SUBSCRIBE user:response:123
    BFF_Service (TypeScript)-->>Client: (user data)

这里使用Redis的Pub/Sub模式进行请求/响应通信,虽然在真实项目中可能会用更健壮的RPC或消息队列,但作为本地开发环境的示例,它足够轻量且能有效验证服务间的交互。

第一步:TDD构建Java用户服务

我们从用户服务开始,严格遵循TDD的“红-绿-重构”循环。

1. 编写失败的测试(红)

首先,我们需要一个测试来验证服务能否正确地从Redis获取用户信息并响应。这里使用Testcontainers来在测试环境中启动一个临时的Redis实例,避免依赖外部环境。

pom.xml 核心依赖:

<dependencies>
    <!-- Jedis for Redis client -->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>5.1.0</version>
    </dependency>
    <!-- Jackson for JSON serialization -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.16.1</version>
    </dependency>

    <!-- Testing Dependencies -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.10.1</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <version>1.19.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>1.19.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>redis</artifactId>
        <version>1.19.3</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.awaitility</groupId>
        <artifactId>awaitility</artifactId>
        <version>4.2.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

UserServiceTest.java:

package com.example.user;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;

import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

@Testcontainers
class UserServiceTest {

    @Container
    public static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
            .withExposedPorts(6379);

    private static String redisHost;
    private static Integer redisPort;

    @BeforeAll
    static void beforeAll() {
        redisHost = redis.getHost();
        redisPort = redis.getMappedPort(6379);
    }

    @Test
    void shouldListenToQueryAndPublishResponse() throws InterruptedException {
        // 1. 启动服务在一个单独的线程中,模拟后台运行
        Thread serviceThread = new Thread(() -> {
            // 在真实场景中,这里会注入配置。为了测试简化,直接传入。
            UserService service = new UserService(redisHost, redisPort);
            service.start();
        });
        serviceThread.setDaemon(true); // 设置为守护线程,确保测试结束时能退出
        serviceThread.start();
        
        // 等待服务订阅成功。在真实项目中,这应该有更可靠的健康检查机制。
        // 这里为了简化,我们仅等待一小段时间。
        Thread.sleep(1000);

        // 2. 准备测试客户端和响应队列
        BlockingQueue<String> responseQueue = new LinkedBlockingQueue<>();
        try (Jedis testSubscriber = new Jedis(redisHost, redisPort)) {
            new Thread(() -> {
                testSubscriber.subscribe(new JedisPubSub() {
                    @Override
                    public void onMessage(String channel, String message) {
                        responseQueue.offer(message);
                    }
                }, "user:response:test-user-123");
            }).start();
        }

        // 3. 模拟BFF发布查询请求
        try (Jedis testPublisher = new Jedis(redisHost, redisPort)) {
            // 预先在Redis中存入用户数据,模拟数据库
            testPublisher.set("user:data:test-user-123", "{\"id\":\"test-user-123\",\"name\":\"Test User\"}");
            testPublisher.publish("user:query:test-user-123", ""); // message can be empty
        }

        // 4. 断言:在规定时间内收到正确的响应
        String response = responseQueue.poll(5, TimeUnit.SECONDS);
        assertNotNull(response, "Did not receive response within 5 seconds");
        assertEquals("{\"id\":\"test-user-123\",\"name\":\"Test User\"}", response);
        
        // 清理并中断服务线程
        serviceThread.interrupt();
    }
}

此时运行mvn test,测试会失败,因为UserService类根本不存在。

2. 编写最小实现(绿)

现在,我们编写最少的代码让测试通过。

User.java (数据模型):

package com.example.user;

public class User {
    private String id;
    private String name;
    // Getters and setters...
}

UserService.java:

package com.example.user;

import com.fasterxml.jackson.databind.ObjectMapper;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPubSub;
import java.io.IOException;

public class UserService {

    private final JedisPool jedisPool;
    private final ObjectMapper objectMapper = new ObjectMapper();

    public UserService(String host, int port) {
        this.jedisPool = new JedisPool(host, port);
    }

    public void start() {
        System.out.println("User service started, listening for queries...");
        // 订阅操作是阻塞的,需要在新线程中运行,以防主线程被卡住
        new Thread(() -> {
            try (Jedis jedis = jedisPool.getResource()) {
                jedis.subscribe(new QuerySubscriber(), "user:query:*");
            }
        }).start();
    }
    
    // 主线程可以做其他事,或者像这里一样简单地等待中断
    public void blockUntilShutdown() {
        try {
            Thread.currentThread().join();
        } catch (InterruptedException e) {
            System.out.println("User service shutting down.");
            jedisPool.close();
            Thread.currentThread().interrupt();
        }
    }

    class QuerySubscriber extends JedisPubSub {
        @Override
        public void onPMessage(String pattern, String channel, String message) {
            System.out.printf("Received query on channel: %s%n", channel);
            String userId = channel.substring("user:query:".length());
            
            try (Jedis jedis = jedisPool.getResource()) {
                // 1. 从Redis中获取用户数据(模拟数据库查询)
                String userDataJson = jedis.get("user:data:" + userId);

                if (userDataJson != null) {
                    // 2. 将查询结果发布到对应的响应频道
                    String responseChannel = "user:response:" + userId;
                    jedis.publish(responseChannel, userDataJson);
                    System.out.printf("Published response for user %s to channel %s%n", userId, responseChannel);
                } else {
                    System.out.printf("User data not found for id: %s%n", userId);
                }
            } catch (Exception e) {
                // 生产级的代码需要更完善的错误处理和日志
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        // 从环境变量获取Redis配置,这是容器化应用的最佳实践
        String redisHost = System.getenv().getOrDefault("REDIS_HOST", "localhost");
        int redisPort = Integer.parseInt(System.getenv().getOrDefault("REDIS_PORT", "6379"));

        UserService service = new UserService(redisHost, redisPort);
        service.start();
        
        // 保持主线程存活
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
             System.out.println("Shutdown hook triggered.");
        }));
        service.blockUntilShutdown();
    }
}

再次运行mvn test,测试通过。

3. 集成Jib进行容器化

现在配置Jib。在pom.xml<build><plugins>部分加入:

<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <version>3.4.0</version>
    <configuration>
        <from>
            <!-- 使用一个不含shell的、精简的基础镜像 -->
            <image>gcr.io/distroless/java17-debian11</image>
        </from>
        <to>
            <!-- 定义构建出的镜像名称 -->
            <image>polyglot-tdd/service-java-user:latest</image>
        </to>
        <container>
            <mainClass>com.example.user.UserService</mainClass>
            <!-- 通过环境变量向容器内部传递配置 -->
            <environment>
                <REDIS_HOST>redis</REDIS_HOST>
                <REDIS_PORT>6379</REDIS_PORT>
            </environment>
        </container>
    </configuration>
</plugin>

这里的坑在于:Jib构建的镜像默认是不可变的,并且为了安全使用了distroless基础镜像,它不包含shell。这意味着你无法docker exec -it <container> /bin/sh进去调试。所有配置必须通过环境变量或挂载的配置文件传入,这是云原生应用的一个良好实践。

执行mvn compile jib:dockerBuild,Jib会直接将镜像构建到本地Docker守护进程中,速度远超docker build

第二步:TDD构建TypeScript BFF服务

现在轮到BFF服务,我们将使用Rome。

1. 初始化项目并配置Rome

cd service-ts-bff
npm init -y
npm install express redis @types/express
npx rome init

这会生成一个rome.json文件,我们几乎不需要修改它,默认配置已经足够强大。

2. 编写失败的测试(红)

Rome内置了测试运行器,语法与Jest类似。

src/index.test.ts:

import { test } from "rome";
import { Server } from "http";
import express from "express";
import { createClient } from "redis";
import request from "supertest"; // 需要 `npm install supertest @types/supertest --save-dev`

// 这是一个关键的mocking技巧
// 我们需要mock redis client,防止测试连接真实的redis
// jest.mock('redis') 在 Rome 中不直接可用,但我们可以用依赖注入的思路解决
// 这里为了简化,我们让测试依赖一个真实的Redis,但指向Testcontainers
// 一个更高级的方案是使用如 'redis-mock' 的库
type RedisClientType = ReturnType<typeof createClient>;

import { createApp } from "./index";

test("GET /user/:id should retrieve user data via Redis", async (t) => {
    // 1. 启动一个临时的Redis实例 (在真实项目中,这部分可以抽成测试基类)
    // 注意:Rome测试环境目前没有像JUnit那样的生命周期钩子,
    // 所以我们需要手动管理容器。这里为了演示,我们假设Redis已在外部(如docker-compose)运行。
    // 在CI中,这会是一个独立的步骤。
    const redisTestClient = createClient({
        url: 'redis://localhost:6379'
    });
    await redisTestClient.connect();

    // 2. 创建应用实例,并注入Redis客户端
    const app = createApp(redisTestClient as RedisClientType);

    // 3. 预设数据并模拟Java服务的响应
    const userId = "test-user-456";
    const userData = { id: userId, name: "Another Test User" };
    
    // 使用一个独立的客户端来发布响应,模拟Java Service的行为
    const responder = redisTestClient.duplicate();
    await responder.connect();
    
    // 关键点:在请求发起后再发布响应,模拟异步通信
    setTimeout(() => {
        responder.publish(`user:response:${userId}`, JSON.stringify(userData));
    }, 100); // 延迟100ms确保BFF服务已订阅

    // 4. 发起HTTP请求并断言
    const response = await request(app).get(`/user/${userId}`);

    t.is(response.status, 200);
    t.assert.deepEqual(response.body, userData);

    // 5. 清理
    await redisTestClient.disconnect();
    await responder.disconnect();
});

运行npx rome test,测试会失败,因为/user/:id路由不存在。

3. 编写最小实现(绿)

src/index.ts:

import express, { Request, Response } from 'express';
import { createClient } from 'redis';

type RedisClientType = ReturnType<typeof createClient>;

// 将创建App的逻辑导出,方便测试时注入mock的依赖
export function createApp(redisClient: RedisClientType) {
    const app = express();

    app.get('/user/:id', async (req: Request, res: Response) => {
        const userId = req.params.id;
        const queryChannel = `user:query:${userId}`;
        const responseChannel = `user:response:${userId}`;
        
        // 使用一个独立的subscriber客户端,因为一旦进入订阅模式,连接就不能执行其他命令
        const subscriber = redisClient.duplicate();
        await subscriber.connect();

        const timeout = setTimeout(() => {
            subscriber.unsubscribe(responseChannel);
            res.status(408).json({ error: 'Request timed out' });
        }, 5000); // 5秒超时

        await subscriber.subscribe(responseChannel, (message) => {
            clearTimeout(timeout);
            console.log(`Received response on ${responseChannel}: ${message}`);
            try {
                const userData = JSON.parse(message);
                res.status(200).json(userData);
            } catch (e) {
                res.status(500).json({ error: 'Failed to parse user data' });
            } finally {
                subscriber.unsubscribe(responseChannel);
                subscriber.quit();
            }
        });

        console.log(`Publishing query to ${queryChannel}`);
        // 发布查询请求
        await redisClient.publish(queryChannel, '');
    });
    
    return app;
}


// 只有在非测试环境下才启动服务器监听
if (process.env.NODE_ENV !== 'test') {
    const redisClient = createClient({
        url: `redis://${process.env.REDIS_HOST || 'localhost'}:${process.env.REDIS_PORT || 6379}`
    });
    
    redisClient.on('error', err => console.log('Redis Client Error', err));
    
    redisClient.connect().then(() => {
        const app = createApp(redisClient);
        const port = process.env.PORT || 3000;
        app.listen(port, () => {
            console.log(`BFF service listening on port ${port}`);
        });
    });
}

再次运行npx rome test,测试通过。Rome的反馈速度极快,几乎是瞬时的。

4. 容器化BFF服务

对于Node.js应用,我们仍然需要一个Dockerfile,但可以做很多优化。

service-ts-bff/Dockerfile:

# --- Build Stage ---
FROM node:18-alpine AS build
WORKDIR /app

# 仅复制 package.json 和 lock 文件,利用Docker层缓存
COPY package*.json ./
RUN npm ci

# 复制源代码并构建
COPY . .
# 这里如果需要编译步骤,例如 `npx rome compile` 或 `tsc`,应在此处执行
# 由于我们的代码可以直接运行,此步骤省略

# --- Production Stage ---
FROM node:18-alpine
WORKDIR /app

# 从构建阶段复制 node_modules 和源代码
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/src ./src
COPY --from=build /app/package.json ./package.json

# 设置环境变量
ENV NODE_ENV=production
ENV REDIS_HOST=redis
ENV REDIS_PORT=6379

EXPOSE 3000
CMD ["node", "src/index.js"]

这是一个标准的多阶段构建Dockerfile,可以有效减小最终镜像体积并利用构建缓存。

第三步:使用Makefile和Docker Compose统一工作流

最后一步,将所有东西串起来。

docker-compose.yml:

version: '3.8'
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  user-service:
    build:
      context: ./service-java-user
      # 使用Jib构建,因此这里实际上不需要Dockerfile
      # 但是为了让docker-compose build能工作,我们需要一个“假”的构建步骤
      # 更好的方式是在Makefile中独立控制jib的构建
    image: polyglot-tdd/service-java-user:latest
    depends_on:
      - redis
    # environment 在 Jib 的 pom.xml 中已经定义,这里是备用

  bff-service:
    build:
      context: ./service-ts-bff
    image: polyglot-tdd/service-ts-bff:latest
    ports:
      - "3000:3000"
    depends_on:
      - redis
      - user-service

Makefile:

.PHONY: all test build up down clean

# 默认目标
all: test build up

# 统一运行所有测试
test: test-java test-ts
	@echo "✅ All tests passed."

test-java:
	@echo "🧪 Running Java service tests..."
	@cd service-java-user && mvn -B test

test-ts:
	@echo "🧪 Running TypeScript BFF service tests..."
	@cd service-ts-bff && npx rome test

# 统一构建所有镜像
build: build-java build-ts
	@echo "📦 All images built."

build-java:
	@echo "📦 Building Java service image with Jib..."
	@cd service-java-user && mvn -B compile jib:dockerBuild

build-ts:
	@echo "📦 Building TypeScript BFF service image..."
	@docker compose build bff-service

# 启动整个环境
up:
	@echo "🚀 Starting all services..."
	@docker compose up -d

# 停止并移除容器
down:
	@echo "🛑 Stopping all services..."
	@docker compose down

# 清理构建产物
clean:
	@echo "🧹 Cleaning up..."
	@cd service-java-user && mvn -B clean
	@docker compose down -v --rmi all
	@echo "Cleanup complete."

现在,开发者的工作流变得异常简单:

  1. 修改代码后,运行 make test。亚秒级的反馈来自Rome,秒级的反馈来自Java单元测试。
  2. 测试通过后,运行 make build。Jib的增量构建极快,通常也只需几秒。TS镜像构建会利用缓存。
  3. 运行 make up 启动整个环境,然后可以通过curl http://localhost:3000/user/some-id 进行端到端测试。
  4. make downmake clean 负责环境的销毁和清理。

这套体系的局限性在于,它主要优化的是本地开发和CI中的构建测试环节。对于生产环境的部署、监控、服务发现等,还需要Kubernetes、Istio、Prometheus等更复杂的工具栈来支持。此外,Rome作为一个仍在快速发展的项目,其测试功能和生态(例如复杂的mock库)相较于Jest等成熟方案可能还有差距,技术选型时需要评估团队对这些高级功能的需求程度。我们选择它是为了极致的性能和工具链的统一性,这在当前阶段是值得的权衡。


  目录