团队内部的多语言(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 /app/node_modules ./node_modules
COPY /app/src ./src
COPY /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."
现在,开发者的工作流变得异常简单:
- 修改代码后,运行
make test
。亚秒级的反馈来自Rome,秒级的反馈来自Java单元测试。 - 测试通过后,运行
make build
。Jib的增量构建极快,通常也只需几秒。TS镜像构建会利用缓存。 - 运行
make up
启动整个环境,然后可以通过curl http://localhost:3000/user/some-id
进行端到端测试。 -
make down
和make clean
负责环境的销毁和清理。
这套体系的局限性在于,它主要优化的是本地开发和CI中的构建测试环节。对于生产环境的部署、监控、服务发现等,还需要Kubernetes、Istio、Prometheus等更复杂的工具栈来支持。此外,Rome作为一个仍在快速发展的项目,其测试功能和生态(例如复杂的mock库)相较于Jest等成熟方案可能还有差距,技术选型时需要评估团队对这些高级功能的需求程度。我们选择它是为了极致的性能和工具链的统一性,这在当前阶段是值得的权衡。