构建跨栈领域语言守护 实现自定义 ESLint 规则对齐 Flutter Redux 与 Serverless API


项目初期,我们遇到了一个难以追踪的生产环境问题。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 的类型定义?

这个想法最终演变成一个具体的实现路径:

  1. 契约提取: 编写一个 Dart 脚本,通过分析 Redux 的 Action 和 State 定义,自动生成一份描述领域模型的 JSON Schema。这份 Schema 就是我们跨栈的“通用语言”契约。
  2. 规则引擎: 开发一个自定义的 ESLint 规则。这个规则会读取这份 JSON Schema。
  3. 静态守护: 在后端的 CI 流程中,运行这个 ESLint 规则来扫描所有 Serverless handler 的 TypeScript 代码。它会解析代码的抽象语法树(AST),检查 API 的输入输出是否严格遵守 Schema 定义。

这个方案的优势在于它的非侵入性。前端团队可以继续以他们最熟悉的方式定义 Redux 状态,而后端只需要在 package.json 中增加一个 devDependency 并配置一下 .eslintrc.js。整个一致性校验过程在开发和 CI 阶段完成,远早于代码进入生产环境。

第一步:定义领域模型并生成契约

我们的 Flutter 应用使用 reduxflutter_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_genanalyzer 包来静态分析 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 项目中集成这个插件。

  1. 安装插件:

    # 假设插件发布到了 npm,或者使用本地路径安装
    npm install --save-dev eslint-plugin-domain-guardian
    # 也需要安装 eslint
    npm install --save-dev eslint
  2. 拷贝 Schema:
    将之前生成的 domain-schema.json 文件拷贝到 Serverless 项目的根目录。在真实的 CI/CD 流程中,这一步可以通过脚本自动化,或者使用 Git Submodule 等方式共享。

  3. 配置 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' } // 传递参数
                    ]
                }
            }
        ]
    };
  4. 编写有问题的 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_idorder_items 这两个必需的属性。开发者可以立即定位问题,将代码修正为:

// 修正后的代码
const {
    user_id: userId, // 使用解构别名
    order_items: orderItems,
} = JSON.parse(event.body);

修正后再次运行 ESLint,所有错误消失。CI 流程可以通过,代码可以安全地部署。

方案的局限性与未来展望

这个方案有效地解决了我们最初遇到的跨栈数据结构不一致的问题,但它并非银弹。当前的实现存在一些局限性:

  1. 单向契约: 校验是单向的,从 Flutter Redux(事实之源)到 Serverless 后端。如果后端 API 需要演进并引入新的字段,这个流程无法反向通知前端进行更新。
  2. Schema 生成: 目前的 Schema 生成脚本是手动的,非常脆弱。一个健壮的实现需要利用 package:analyzer 对 Dart 源代码进行静态分析,自动、精确地生成 Schema。
  3. 校验深度: 我们的 ESLint 规则只检查了解构赋值的第一层。对于深层嵌套的对象和复杂的类型逻辑(如联合类型、泛型),AST 的分析会变得非常复杂,规则的健壮性会面临挑战。
  4. 配置负担: overrides 配置要求为每个 handler 文件单独指定 actionName。虽然精确,但在大型项目中可能变得繁琐。可以探索基于文件命名约定或代码内注释(/* eslint-action-name: CreateOrderAction */)的自动化关联机制。

尽管存在这些局限,这个方案为我们团队提供了一个高性价比的解决方案。它没有引入新的技术栈或重量级框架,而是巧妙地扩展了我们已有的工具链(ESLint),将领域模型的“通用语言”从文档和口头约定,真正落实到了代码的静态校验中,这在工程实践中具有显著的价值。未来的迭代方向可能包括引入双向契约检查,或者当项目复杂度进一步提升时,最终转向一个更完备的、基于 IDL(接口定义语言)的代码生成方案。


  目录