构建基于gRPC契约的全栈类型安全 从Scala后端到Next.js前端的端到端测试实践


团队最近接手的一个项目中,一个经典的线上故障再次上演:后端一位同事为了优化,将一个API响应中的userId字段类型从string改为了number。这个变更在后端的所有单元测试中都顺利通过,Swagger文档也更新了。然而,依赖这个字段的前端代码并没有得到通知,CI流水线也对此毫无察觉。结果就是,新版本上线后,用户个人中心页面白屏,因为前端代码在对一个number类型执行字符串操作时崩溃了。

这种问题在基于JSON的RESTful API协作模式中几乎无法根除。文档可能过时,沟通可能遗漏,而最关键的是,在后端和前端两个独立的技术栈之间,缺乏一个强制性的、自动化的契约来保证类型一致性。我们的目标,就是从根本上解决这个问题,建立一个在CI/CD层面就能强制校验、跨越语言边界的全栈类型安全体系。

技术选型决策很快就明确了:使用gRPC和Protocol Buffers作为前后端通信的基石。Protobuf文件将作为我们唯一的、不可辩驳的“事实之源”(Single Source of Truth)。后端采用Scala,利用其强大的类型系统和生态(Akka gRPC / ZIO gRPC)来构建高性能服务;前端则使用Next.js与TypeScript,将类型安全延伸至UI层面。而将这一切粘合起来,并确保其健壮性的核心,就是一套自动化的端到端(E2E)测试策略。

第一步:定义不可动摇的契约

一切都始于.proto文件。我们将所有服务定义、消息体结构都放在一个共享的proto目录中,这是整个项目的核心契约。为了方便管理,我们采用了一个简单的monorepo结构。

/
├── backend/            # Scala gRPC 服务
│   ├── build.sbt
│   └── src/
├── frontend/           # Next.js 应用
│   ├── package.json
│   └── src/
└── proto/              # 共享的 Protobuf 契约
    └── user_service.proto

我们的契约文件user_service.proto定义了一个简单的用户服务,但包含了嵌套消息和枚举,以模拟真实场景的复杂性。

// proto/user_service.proto
syntax = "proto3";

package users.v1;

import "google/protobuf/timestamp.proto";

// 定义用户服务的 RPC 接口
service UserService {
  // 根据用户ID获取用户信息
  rpc GetUser(GetUserRequest) returns (User);
  // 创建新用户
  rpc CreateUser(CreateUserRequest) returns (User);
}

// 用户状态枚举
enum UserStatus {
  USER_STATUS_UNSPECIFIED = 0;
  ACTIVE = 1;
  INACTIVE = 2;
  SUSPENDED = 3;
}

// GetUser RPC 的请求体
message GetUserRequest {
  string user_id = 1; // 用户的唯一标识符
}

// CreateUser RPC 的请求体
message CreateUserRequest {
  string username = 1;
  string email = 2;
  Profile profile = 3;
}

// 用户的个人资料
message Profile {
  optional string full_name = 1;
  optional string avatar_url = 2;
}

// 用户信息的核心数据结构
message User {
  string user_id = 1;
  string username = 2;
  string email = 3;
  UserStatus status = 4;
  Profile profile = 5;
  google.protobuf.Timestamp created_at = 6;
}

这个文件就是我们的法律。任何对它的修改都必须经过后端和前端的双重验证。

第二步:实现健壮的Scala gRPC后端

我们选择Akka gRPC来构建后端服务。首先,配置build.sbt以启用akka-grpc代码生成插件。

// backend/build.sbt

val AkkaVersion = "2.8.5"
val AkkaHttpVersion = "10.5.3"
val ScalaTestVersion = "3.2.17"

lazy val root = (project in file("."))
  .enablePlugins(AkkaGrpcPlugin)
  .settings(
    name := "scala-grpc-server",
    version := "0.1.0-SNAPSHOT",
    scalaVersion := "2.13.12",
    libraryDependencies ++= Seq(
      "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion,
      "com.typesafe.akka" %% "akka-http-spray-json" % AkkaHttpVersion,
      "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion,
      "com.typesafe.akka" %% "akka-stream" % AkkaVersion,
      "com.typesafe.akka" %% "akka-discovery" % AkkaVersion,
      // 日志
      "ch.qos.logback" % "logback-classic" % "1.4.11",
      // 测试依赖
      "com.typesafe.akka" %% "akka-http-testkit" % AkkaHttpVersion % Test,
      "com.typesafe.akka" %% "akka-actor-testkit-typed" % AkkaVersion % Test,
      "org.scalatest" %% "scalatest" % ScalaTestVersion % Test
    ),
    // 指定 protobuf 源文件目录
    Compile / PB.protoSources := Seq(file("../proto"))
  )

akka-grpc插件会自动在sbt compile期间扫描proto目录,并生成相应的Scala gRPC服务接口 (UserService) 和消息类。我们的任务就是实现这个接口。

// backend/src/main/scala/com/example/UserServiceImpl.scala
package com.example

import users.v1._
import scala.concurrent.{ExecutionContext, Future}
import java.util.UUID
import com.google.protobuf.timestamp.Timestamp
import java.time.Instant
import org.slf4j.LoggerFactory

// 这是一个内存中的伪数据库,用于演示
// 在真实项目中,这里会是一个数据库访问层
object UserRepository {
  private var users = Map.empty[String, User]
  def findById(userId: String): Future[Option[User]] = Future.successful(users.get(userId))
  def save(user: User): Future[User] = {
    users = users + (user.userId -> user)
    Future.successful(user)
  }
}

class UserServiceImpl(implicit ec: ExecutionContext) extends UserService {
  private val logger = LoggerFactory.getLogger(classOf[UserServiceImpl])

  override def getUser(in: GetUserRequest): Future[User] = {
    logger.info(s"Received GetUser request for user_id: ${in.userId}")
    UserRepository.findById(in.userId).flatMap {
      case Some(user) => Future.successful(user)
      case None =>
        // 在真实项目中,错误处理会更复杂,这里仅返回一个 gRPC 状态码
        logger.warn(s"User with user_id: ${in.userId} not found.")
        Future.failed(new io.grpc.StatusRuntimeException(io.grpc.Status.NOT_FOUND.withDescription(s"User ${in.userId} not found")))
    }
  }

  override def createUser(in: CreateUserRequest): Future[User] = {
    logger.info(s"Received CreateUser request for username: ${in.username}")
    val newUser = User(
      userId = UUID.randomUUID().toString,
      username = in.username,
      email = in.email,
      status = UserStatus.ACTIVE,
      profile = in.profile,
      createdAt = Some(Timestamp.fromJavaProto(Instant.now()))
    )
    UserRepository.save(newUser)
  }
}

为了确保服务逻辑的正确性,我们需要编写单元测试。注意,这只是单元测试,它不涉及网络,只验证UserServiceImpl的业务逻辑。

// backend/src/test/scala/com/example/UserServiceSpec.scala
package com.example

import org.scalatest.wordspec.AsyncWordSpec
import org.scalatest.matchers.should.Matchers
import users.v1._
import scala.concurrent.Future

class UserServiceSpec extends AsyncWordSpec with Matchers {
  
  // 使用 ScalaTest 的 AsyncWordSpec 来处理 Future
  implicit val ec = scala.concurrent.ExecutionContext.global
  val service = new UserServiceImpl

  "UserService" should {
    "create a new user and retrieve it" in {
      val createUserRequest = CreateUserRequest(
        username = "johndoe",
        email = "[email protected]",
        profile = Some(Profile(fullName = Some("John Doe")))
      )

      for {
        createdUser <- service.createUser(createUserRequest)
        _ = createdUser.username shouldBe "johndoe"
        _ = createdUser.profile.flatMap(_.fullName) shouldBe Some("John Doe")
        
        retrievedUser <- service.getUser(GetUserRequest(userId = createdUser.userId))
      } yield {
        retrievedUser.userId shouldBe createdUser.userId
        retrievedUser.email shouldBe "[email protected]"
        retrievedUser.status shouldBe UserStatus.ACTIVE
      }
    }

    "return a NOT_FOUND error for a non-existent user" in {
      recoverToSucceededIf[io.grpc.StatusRuntimeException] {
        service.getUser(GetUserRequest(userId = "non-existent-id"))
      }.map { _ =>
        // 测试成功捕获到了预期的异常
        succeed 
      }
    }
  }
}

第三步:在Next.js中消费类型安全的API

现在轮到前端了。我们需要生成TypeScript客户端代码。这需要protoc编译器和几个插件。我们在package.json中设置一个脚本来自动化这个过程。

// frontend/package.json
{
  "name": "nextjs-grpc-client",
  "scripts": {
    "protoc": "protoc --proto_path=../proto --plugin=protoc-gen-grpc-web=./node_modules/.bin/protoc-gen-grpc-web --js_out=import_style=typescript,binary:./src/generated --grpc-web_out=import_style=typescript,mode=grpcwebtext:./src/generated ../proto/user_service.proto"
  },
  "dependencies": {
    "next": "14.0.0",
    "react": "18.2.0",
    "google-protobuf": "^3.21.2",
    "grpc-web": "^1.5.0"
  },
  "devDependencies": {
    "@types/node": "20.8.9",
    "@types/react": "18.2.33",
    "typescript": "5.2.2",
    "protoc-gen-grpc-web": "^1.5.0"
  }
}

运行npm run protoc后,frontend/src/generated目录下会生成所有必需的TS文件和类型定义。现在,我们可以在Next.js组件中使用它,并享受端到端的类型安全。

// frontend/src/components/UserProfile.tsx
import { useEffect, useState } from 'react';
import { User, GetUserRequest } from '../generated/user_service_pb';
import { UserServiceClient } from '../generated/user_service_grpc_web_pb';

// 在实际应用中,这个客户端实例会被封装和复用
// 这里的地址指向 gRPC-Web 代理,例如 Envoy
const client = new UserServiceClient('http://localhost:8080');

export default function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const request = new GetUserRequest();
    request.setUserId(userId);

    client.getUser(request, {}, (err, response) => {
      if (err) {
        setError(`Error: ${err.message}`);
        return;
      }
      setUser(response);
    });
  }, [userId]);

  if (error) return <div>{error}</div>;
  if (!user) return <div>Loading...</div>;
  
  // 这里的每个 .getXXX() 方法都具有完全的类型提示和检查
  // 如果你尝试 user.getNonExistentField(),TypeScript会立即报错
  return (
    <div>
      <h1>{user.getProfile()?.getFullName()}</h1>
      <p>ID: {user.getUserId()}</p>
      <p>Username: {user.getUsername()}</p>
      <p>Email: {user.getEmail()}</p>
      <p>Status: {user.getStatus()}</p> 
    </div>
  );
}

至此,我们在开发环境实现了类型安全。但如何保证部署到生产环境的系统依然遵守契约?

第四步:终极武器 Testcontainers 实现自动化端到端测试

真正的挑战在于CI流水线。我们需要一种方法,在每次提交代码时,都能验证Next.js前端能够与最新版本的Scala后端正确通信。这意味着,在运行前端测试时,必须有一个真实的、可用的后端gRPC服务实例。

在CI环境中手动部署一个后端实例是复杂且脆弱的。这里的最佳实践是使用Testcontainers。它允许我们在测试代码中,以编程方式定义、启动和销毁Docker容器。

我们将使用Jest作为测试运行器,并集成@testcontainers/node

首先,为我们的Scala服务创建一个Dockerfile

# backend/Dockerfile
FROM eclipse-temurin:11-jre-focal

WORKDIR /app

# sbt assembly 插件会生成一个包含所有依赖的 fat jar
COPY target/scala-2.13/scala-grpc-server-assembly-0.1.0-SNAPSHOT.jar app.jar

EXPOSE 8080

CMD ["java", "-jar", "app.jar"]

现在,编写我们的端到端集成测试。这个测试文件将是整个类型安全体系的守护者。

// frontend/src/tests/userService.integration.test.ts
import { GenericContainer, Wait } from 'testcontainers';
import path from 'path';

import { UserServiceClient } from '../generated/user_service_grpc_web_pb';
import { CreateUserRequest, GetUserRequest, Profile } from '../generated/user_service_pb';
import { User } from '../generated/user_service_pb';
import * as grpc from 'grpc-web';

describe('UserService E2E', () => {
  let container: Awaited<ReturnType<typeof GenericContainer.start>>;
  let client: UserServiceClient;

  // 在所有测试开始前,构建并启动 Scala gRPC 服务的 Docker 容器
  beforeAll(async () => {
    const backendPath = path.resolve(__dirname, '../../../backend');
    
    // Testcontainers 会在后台调用 Docker 来构建镜像
    const image = await GenericContainer.fromDockerfile(backendPath).build();

    console.log('Starting gRPC server container...');
    container = await image
      .withExposedPorts(8080) // 暴露容器的 8080 端口
      .withWaitStrategy(Wait.forLogMessage(/gRPC server bound to/)) // 等待日志输出,确认服务已启动
      .start();

    const host = container.getHost();
    const port = container.getMappedPort(8080);
    const serviceUrl = `http://${host}:${port}`;
    console.log(`gRPC server running at ${serviceUrl}`);
    
    // 创建一个指向容器内服务的客户端
    client = new UserServiceClient(serviceUrl);
  }, 300000); // 增加超时时间,因为构建和启动容器可能需要一些时间

  // 所有测试结束后,销毁容器
  afterAll(async () => {
    if (container) {
      await container.stop();
      console.log('gRPC server container stopped.');
    }
  });

  // 测试用例:验证创建和获取用户的完整流程
  test('should create and then get a user', async () => {
    const profile = new Profile();
    profile.setFullName('Jane Doe');
    profile.setAvatarUrl('http://example.com/avatar.png');

    const createRequest = new CreateUserRequest();
    createRequest.setUsername('janedoe');
    createRequest.setEmail('[email protected]');
    createRequest.setProfile(profile);

    // 使用 Promise 包装回调式的 gRPC 调用
    const createdUser = await new Promise<User>((resolve, reject) => {
      client.createUser(createRequest, {}, (err, response) => {
        if (err) reject(err);
        else resolve(response);
      });
    });

    expect(createdUser.getUsername()).toBe('janedoe');
    expect(createdUser.getProfile()?.getFullName()).toBe('Jane Doe');
    
    const getRequest = new GetUserRequest();
    getRequest.setUserId(createdUser.getUserId());

    const retrievedUser = await new Promise<User>((resolve, reject) => {
      client.getUser(getRequest, {}, (err, response) => {
        if (err) reject(err);
        else resolve(response);
      });
    });

    expect(retrievedUser.toObject()).toEqual(createdUser.toObject());
  });

  test('should return NOT_FOUND for a non-existent user', async () => {
    const getRequest = new GetUserRequest();
    getRequest.setUserId('a-fake-id-that-does-not-exist');

    const error = await new Promise<grpc.RpcError>((resolve) => {
      client.getUser(getRequest, {}, (err, _) => {
        resolve(err!);
      });
    });
    
    // grpc-web 将 gRPC 状态码映射到自己的错误码
    // Status.NOT_FOUND 对应 code 5
    expect(error.code).toBe(grpc.StatusCode.NOT_FOUND);
    expect(error.message).toContain('User a-fake-id-that-does-not-exist not found');
  });
});

这个测试做了什么?它在运行jest命令时,自动执行了以下操作:

  1. 找到后端Scala项目的Dockerfile
  2. 使用本地的Docker环境,从源代码构建一个全新的、干净的后端服务镜像。
  3. 启动这个镜像作为一个容器。
  4. 等待容器内的gRPC服务完全启动。
  5. 将前端测试代码中的gRPC客户端指向这个临时容器的IP和端口。
  6. 执行一系列真实的RPC调用,验证契约的每一部分。
  7. 测试结束后,自动销毁容器,不留下任何垃圾。

最终成果:一个自动化的契约守护流水线

我们将这个E2E测试集成到CI/CD流水线中(例如GitHub Actions)。

graph TD
    A[开发者推送代码] --> B{CI/CD 流水线触发};
    B --> C[后端: sbt compile && sbt assembly];
    C --> D[前端: npm install && npm run protoc];
    D --> E{运行前端 E2E 测试};
    E -- 启动容器 --> F[Testcontainers: docker build & run 后端服务];
    F -- gRPC服务就绪 --> G[Jest: 使用TS客户端调用容器内服务];
    G -- 测试通过 --> H[允许合并/部署];
    G -- 测试失败 --> I[构建失败, 阻塞合并];

现在,如果后端工程师修改了.proto文件(比如将User中的username字段重命名为login_name),会发生什么?

  1. 他/她提交代码,CI流水线启动。
  2. 后端的Scala代码会因为找不到username字段而编译失败。修复后,他/她必须同时更新前端代码。
  3. 如果他/她只更新了后端代码和.proto文件,却没有更新前端的调用代码,那么在npm run protoc之后,前端的TypeScript代码会因为调用不存在的getUsername方法而编译失败。
  4. 如果他/她更新了所有代码,但后端的业务逻辑实现有误(比如错误地返回了null的profile),那么我们的E2E测试会捕获到这个运行时错误,流水线依然会失败。

这个体系的价值在于,它将“契约”从一份静态的文档,变成了一个动态的、可执行的、在每次构建时都必须通过的测试。

局限与展望

当然,这个方案并非没有成本。Testcontainers的引入,特别是每次测试都重新构建Docker镜像,会显著增加CI的运行时间。在大型项目中,需要对Docker的层缓存进行精细优化,或者采用预构建开发镜像的策略来加速。

其次,gRPC-Web本身依赖于一个代理(如Envoy或Nginx gRPC-Web模块)来将HTTP/1.1的请求转换为gRPC请求,这在生产部署架构中增加了一个环节。虽然这通常是标准实践,但也是需要考虑的运维成本。

未来的迭代方向可能包括引入像Buf Schema Registry这样的工具。它可以对.proto文件的变更进行 lint 检查和破坏性变更检测,从而在代码提交之前就发现潜在的契约冲突,将验证过程进一步“左移”。


  目录