项目初期,我们遇到了一个难以追踪的生产环境问题。Flutter 应用在用户执行某个关键操作后,状态并未如预期更新,但日志里没有任何崩溃或显性错误。后端 Serverless 函数(AWS Lambda)的 CloudWatch 日志也显示请求成功,状态码 200。经过数小时的调试,根源被定位到一个极其微小的数据结构差异上:Flutter Redux Action 发送的 order_items
数组,在后端的 TypeScript handler 里被误写为 orderItems
进行了解析。这种跨语言、跨技术栈的“静默失败”是维护领域模型一致性的最大敌人。
最初的构想是引入代码生成,比如基于 Protobuf 或 OpenAPI 规范为 Dart 和 TypeScript 生成统一的类型定义。但在快速迭代的背景下,这套方案显得过于笨重。它引入了额外的构建步骤、学习成本,并且对于已经存在的代码库有较大的侵入性。我们需要的不是一个重量级的框架,而是一个轻量、自动化、能融入现有 CI/CD 流程的“守护进程”,一个能够理解我们领域通用语言(Ubiquitous Language)并强制执行它的工具。
核心矛盾在于:我们的领域模型核心事实(Source of Truth)存在于 Flutter 应用的 Redux Store 定义中,这是业务逻辑最直接的体现。而后端 Serverless API 只是这个模型的一个消费者和操纵者。那么,我们能否让 TypeScript 的静态检查工具直接“学习”Dart 的类型定义?
这个想法最终演变成一个具体的实现路径:
- 契约提取: 编写一个 Dart 脚本,通过分析 Redux 的 Action 和 State 定义,自动生成一份描述领域模型的 JSON Schema。这份 Schema 就是我们跨栈的“通用语言”契约。
- 规则引擎: 开发一个自定义的 ESLint 规则。这个规则会读取这份 JSON Schema。
- 静态守护: 在后端的 CI 流程中,运行这个 ESLint 规则来扫描所有 Serverless handler 的 TypeScript 代码。它会解析代码的抽象语法树(AST),检查 API 的输入输出是否严格遵守 Schema 定义。
这个方案的优势在于它的非侵入性。前端团队可以继续以他们最熟悉的方式定义 Redux 状态,而后端只需要在 package.json
中增加一个 devDependency
并配置一下 .eslintrc.js
。整个一致性校验过程在开发和 CI 阶段完成,远早于代码进入生产环境。
第一步:定义领域模型并生成契约
我们的 Flutter 应用使用 redux
和 flutter_redux
包。假设我们正在处理一个订单模块,其核心 State 和 Action 定义如下。这里的代码是整个系统的“事实之源”。
lib/redux/order_state.dart
// lib/redux/order_state.dart
import 'package:meta/meta.dart';
// 使用不可变数据结构是Redux的最佳实践
class OrderItem {
final String productId;
final int quantity;
final double price;
const OrderItem({
required this.productId,
required this.quantity,
required this.price,
});
Map<String, dynamic> toJson() => {
'product_id': productId,
'quantity': quantity,
'price': price,
};
}
class OrderState {
final String orderId;
final String userId;
final List<OrderItem> items;
final bool isLoading;
final String? error;
const OrderState({
required this.orderId,
required this.userId,
this.items = const [],
this.isLoading = false,
this.error,
});
OrderState copyWith({
String? orderId,
String? userId,
List<OrderItem>? items,
bool? isLoading,
String? error,
}) {
return OrderState(
orderId: orderId ?? this.orderId,
userId: userId ?? this.userId,
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
}
// 定义一个具体的Action,它将成为我们与后端API交互的契约
class CreateOrderAction {
final String userId;
final List<OrderItem> orderItems; // 注意这里的字段名是 'orderItems'
const CreateOrderAction({
required this.userId,
required this.orderItems,
});
Map<String, dynamic> toJson() => {
'user_id': userId,
'order_items': orderItems.map((item) => item.toJson()).toList(),
};
}
接下来,我们需要一个脚本来将这些 Dart 定义转换为机器可读的 JSON Schema。在真实项目中,可能会使用 source_gen
或 analyzer
包来静态分析 Dart 代码。为了简化演示,我们这里手动编写一个生成器,模拟这个过程。关键在于捕捉类型、字段名和嵌套结构。
tool/generate_domain_schema.dart
// tool/generate_domain_schema.dart
import 'dart:convert';
import 'dart:io';
// 这是一个简化的、手动的 schema 生成器
// 在真实项目中,这应该通过代码分析自动完成
void main() {
final domainSchema = {
'CreateOrderAction': {
'type': 'object',
'properties': {
'user_id': {'type': 'string'},
'order_items': { // 契约中明确定义了 'order_items'
'type': 'array',
'items': {
'type': 'object',
'properties': {
'product_id': {'type': 'string'},
'quantity': {'type': 'integer'},
'price': {'type': 'number'},
},
'required': ['product_id', 'quantity', 'price'],
},
},
},
'required': ['user_id', 'order_items'],
},
// 此处可以添加更多 Action 或 State 的 Schema
};
final outputFile = File('domain-schema.json');
outputFile.writeAsStringSync(json.encode(domainSchema));
print('✅ Domain schema generated at domain-schema.json');
}
运行 dart run tool/generate_domain_schema.dart
后,我们会在项目根目录得到 domain-schema.json
。这个文件需要被提交到代码库,并被后端的 ESLint 规则所消费。
第二步:实现自定义 ESLint 规则
这是整个方案的核心。我们将创建一个名为 eslint-plugin-domain-guardian
的 ESLint 插件。
首先,初始化插件项目:
mkdir eslint-plugin-domain-guardian
cd eslint-plugin-domain-guardian
npm init -y
mkdir -p lib/rules tests/lib/rules
touch lib/index.js lib/rules/enforce-api-contract.js tests/lib/rules/enforce-api-contract.js
lib/rules/enforce-api-contract.js
这是规则的实现。它会利用 espree
(ESLint 默认的解析器)生成的 AST。我们的目标是找到处理 API 请求的函数,并检查其内部对 event.body
的解析是否符合 domain-schema.json
中定义的结构。
// lib/rules/enforce-api-contract.js
"use strict";
const fs = require('fs');
const path = require('path');
const { Linter } = require('eslint');
// 假设 domain-schema.json 在仓库的根目录
// 实际项目中,路径解析可能需要更健壮的逻辑
const schemaPath = path.resolve(process.cwd(), 'domain-schema.json');
let domainSchema = null;
try {
if (fs.existsSync(schemaPath)) {
domainSchema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
}
} catch (error) {
console.error('Error loading domain-schema.json:', error);
// 如果schema加载失败,规则将无法工作
}
module.exports = {
meta: {
type: "problem",
docs: {
description: "Enforce API contract against the domain schema defined by the Flutter client",
category: "Best Practices",
recommended: true,
},
fixable: null,
schema: [
{
type: 'object',
properties: {
actionName: {
type: 'string'
}
},
additionalProperties: false
}
]
},
create: function(context) {
if (!domainSchema) {
// 如果 schema 文件不存在或加载失败,则不执行任何操作
return {};
}
// 从规则配置中获取当前 handler 应该校验的 Action 名称
const actionName = context.options[0]?.actionName;
if (!actionName || !domainSchema[actionName]) {
return {};
}
const actionSchema = domainSchema[actionName];
/**
* 检查一个对象表达式的属性是否符合 schema 定义
* @param {ASTNode} objectExpressionNode - AST 中代表对象字面量的节点
* @param {Object} schemaProperties - 从 JSON Schema 中获取的 properties 对象
*/
function checkObjectProperties(objectExpressionNode, schemaProperties) {
if (objectExpressionNode.type !== 'ObjectExpression') return;
const expectedKeys = Object.keys(schemaProperties);
const actualKeys = new Set();
for (const prop of objectExpressionNode.properties) {
if (prop.type === 'Property' && prop.key.type === 'Identifier') {
const keyName = prop.key.name;
actualKeys.add(keyName);
// 1. 检查是否存在 schema 中未定义的属性
if (!expectedKeys.includes(keyName)) {
context.report({
node: prop.key,
message: `Property '${keyName}' is not defined in the domain schema for '${actionName}'.`
});
}
// 可以在这里添加更深层次的类型检查,比如检查值的类型
}
}
// 2. 检查是否缺少 schema 中定义的必需属性
for(const expectedKey of expectedKeys) {
if(!actualKeys.has(expectedKey)) {
context.report({
node: objectExpressionNode,
message: `Missing required property '${expectedKey}' from domain schema for '${actionName}'.`
});
}
}
}
return {
// 我们将目标锁定在对 `event.body` 进行 JSON.parse 的赋值语句上
// 例如: const body = JSON.parse(event.body);
VariableDeclarator(node) {
// 确保这是一个变量声明,并且其初始化值是一个调用表达式
if (!node.init || node.init.type !== 'CallExpression') return;
const callee = node.init.callee;
// 检查是否是调用 JSON.parse
if (callee.type !== 'MemberExpression' ||
callee.object.name !== 'JSON' ||
callee.property.name !== 'parse') {
return;
}
// 检查 parse 的参数是否是 event.body
const arg = node.init.arguments[0];
if (!arg || arg.type !== 'MemberExpression' ||
arg.object.name !== 'event' ||
arg.property.name !== 'body') {
return;
}
// 如果是 `const { a, b } = JSON.parse(event.body)` 这种形式
if (node.id.type === 'ObjectPattern') {
checkObjectProperties(node.id, actionSchema.properties);
}
}
};
}
};
lib/index.js
这个文件是插件的入口,它导出了所有规则。
// lib/index.js
"use strict";
module.exports = {
rules: {
"enforce-api-contract": require("./rules/enforce-api-contract")
}
};
第三步:在 Serverless 项目中集成和使用
现在,我们在后端的 Serverless TypeScript 项目中集成这个插件。
安装插件:
# 假设插件发布到了 npm,或者使用本地路径安装 npm install --save-dev eslint-plugin-domain-guardian # 也需要安装 eslint npm install --save-dev eslint
拷贝 Schema:
将之前生成的domain-schema.json
文件拷贝到 Serverless 项目的根目录。在真实的 CI/CD 流程中,这一步可以通过脚本自动化,或者使用 Git Submodule 等方式共享。配置 ESLint:
在.eslintrc.js
文件中启用我们的规则。注意,我们需要为每个 handler 指定它所对应的actionName
,这样规则才知道用哪个 schema 去校验。.eslintrc.js
module.exports = { root: true, parser: '@typescript-eslint/parser', plugins: [ '@typescript-eslint', 'domain-guardian', // 启用我们的插件 ], extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', ], rules: { // 可以在这里配置全局规则 }, overrides: [ { // 只对特定的 handler 文件应用我们的自定义规则 files: ['src/handlers/createOrder.ts'], rules: { 'domain-guardian/enforce-api-contract': [ 'error', // 将其设为错误级别 { actionName: 'CreateOrderAction' } // 传递参数 ] } } ] };
编写有问题的 Serverless Handler:
现在,我们来模拟最初引发问题的那个 bug。开发者在 handler 中使用了驼峰命名
orderItems
,而我们的 schema 要求的是order_items
。src/handlers/createOrder.ts
// src/handlers/createOrder.ts import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; interface OrderItem { productId: string; quantity: number; price: number; } interface CreateOrderPayload { userId: string; orderItems: OrderItem[]; // <-- 这里的命名是错误的 (orderItems vs order_items) } export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => { if (!event.body) { return { statusCode: 400, body: 'Bad Request: Missing body' }; } try { // ESLint 规则将在这里发现问题 const { userId, orderItems } = JSON.parse(event.body) as CreateOrderPayload; // ... 业务逻辑 ... console.log(`Processing order for user ${userId} with ${orderItems.length} items.`); return { statusCode: 201, body: JSON.stringify({ message: 'Order created' }), }; } catch (error) { console.error('Error creating order:', error); return { statusCode: 500, body: 'Internal Server Error', }; } };
当我们在 CI 流程中或本地运行 npx eslint .
时,ESLint 会加载我们的自定义规则,扫描 src/handlers/createOrder.ts
,并报告错误:
/path/to/project/src/handlers/createOrder.ts
17:29 error Missing required property 'user_id' from domain schema for 'CreateOrderAction' domain-guardian/enforce-api-contract
17:29 error Missing required property 'order_items' from domain schema for 'CreateOrderAction' domain-guardian/enforce-api-contract
17:39 error Property 'orderItems' is not defined in the domain schema for 'CreateOrderAction' domain-guardian/enforce-api-contract
✖ 3 problems (3 errors, 0 warnings)
错误信息非常明确:它指出了 orderItems
是一个未定义的属性,并且缺少了 user_id
和 order_items
这两个必需的属性。开发者可以立即定位问题,将代码修正为:
// 修正后的代码
const {
user_id: userId, // 使用解构别名
order_items: orderItems,
} = JSON.parse(event.body);
修正后再次运行 ESLint,所有错误消失。CI 流程可以通过,代码可以安全地部署。
方案的局限性与未来展望
这个方案有效地解决了我们最初遇到的跨栈数据结构不一致的问题,但它并非银弹。当前的实现存在一些局限性:
- 单向契约: 校验是单向的,从 Flutter Redux(事实之源)到 Serverless 后端。如果后端 API 需要演进并引入新的字段,这个流程无法反向通知前端进行更新。
- Schema 生成: 目前的 Schema 生成脚本是手动的,非常脆弱。一个健壮的实现需要利用
package:analyzer
对 Dart 源代码进行静态分析,自动、精确地生成 Schema。 - 校验深度: 我们的 ESLint 规则只检查了解构赋值的第一层。对于深层嵌套的对象和复杂的类型逻辑(如联合类型、泛型),AST 的分析会变得非常复杂,规则的健壮性会面临挑战。
- 配置负担:
overrides
配置要求为每个 handler 文件单独指定actionName
。虽然精确,但在大型项目中可能变得繁琐。可以探索基于文件命名约定或代码内注释(/* eslint-action-name: CreateOrderAction */
)的自动化关联机制。
尽管存在这些局限,这个方案为我们团队提供了一个高性价比的解决方案。它没有引入新的技术栈或重量级框架,而是巧妙地扩展了我们已有的工具链(ESLint),将领域模型的“通用语言”从文档和口头约定,真正落实到了代码的静态校验中,这在工程实践中具有显著的价值。未来的迭代方向可能包括引入双向契约检查,或者当项目复杂度进一步提升时,最终转向一个更完备的、基于 IDL(接口定义语言)的代码生成方案。