构建一个基于 Express 和 Prettier 的容器化代码格式化即服务 (FaaS) 平台


团队扩张到一定规模后,代码风格一致性就成了 CI/CD 流水线中的一个顽疾。起初,我们依赖 pre-commit 钩子和本地的 .prettierrc 文件,但这套体系的脆弱性很快就暴露了:新成员忘记配置钩子、不同项目间的 Prettier 版本冲突、IDE 插件与 CLI 版本不匹配导致 CI 校验失败。这些琐碎的问题不断中断开发流程,浪费了大量时间在解决本应自动化的事情上。我们需要一个更强有力的约束,一个独立于开发者本地环境、集中管控且高可用的代码格式化中心。

这个想法催生了一个内部项目:打造一个“格式化即服务”(Formatting-as-a-Service, FaaS)平台。其核心理念很简单:任何 CI/CD 作业,无论使用何种语言,都可以通过一个简单的 API 调用,将代码片段发送到一个中心服务,该服务负责应用统一的、版本化的 Prettier 规则进行格式化,并返回结果。这种模式将格式化能力从分散的本地环境抽离,变成了一种可被编排、监控和升级的云原生服务。

技术选型与架构决策

要构建这样一个平台,我们需要一个轻量级的 Web 框架、一个强大的打包工具以及一个成熟的容器编排系统。

  1. API 服务层: Express.js 是不二之选。它足够轻量,中间件生态成熟,对于这样一个功能单一的微服务来说,性能绰绰有余。我们不需要复杂的 MVC 框架,只需要一个能处理 JSON 请求和响应的稳定 HTTP 服务器。

  2. 核心格式化引擎: Prettier 的 Node.js API (prettier.format) 是我们实现业务逻辑的基石。我们将通过这个 API 以编程方式调用 Prettier 的格式化能力。

  3. 部署与运维: 鉴于该服务在 CI/CD 流程中的关键地位,高可用性是必须的。Docker 负责将应用及其依赖打包成一个标准化的、不可变的镜像。Kubernetes 则作为容器编排平台,负责服务的部署、自愈、弹性伸缩和配置管理。在真实项目中,这样一个核心服务绝不能是单点部署的。

架构设计如下,这是一个典型的云原生微服务模式:

graph TD
    subgraph "CI/CD Pipeline (e.g., GitHub Actions, Jenkins)"
        A[Code Checkout] --> B{Send Code via HTTP POST};
    end

    subgraph "Kubernetes Cluster"
        C[Ingress/LoadBalancer] --> D[Service];
        D --> E1[Pod 1: Express App];
        D --> E2[Pod 2: Express App];
        D --> E3[Pod N: Express App];

        subgraph "Each Pod"
            F[Container: Node.js] --> G[Express.js Server];
            G --> H[Prettier API];
        end

        I[ConfigMap: .prettierrc] -.-> E1;
        I -.-> E2;
        I -.-> E3;
    end

    B --> C;
    E1 --> J[Formatted Code Response];
    E2 --> J;
    E3 --> J;
    J --> B;

这个架构的核心优势在于解耦和集中管理。Prettier 的版本和规则配置由 ConfigMap 统一管理,更新规则只需修改 ConfigMap 并触发 Pod 滚动更新,无需修改任何代码或重建镜像。Kubernetes 的 Deployment 确保了服务始终有多个副本在运行,Service 对象则提供了稳定的访问入口。

核心服务实现:Express.js 与 Prettier 的结合

我们的 Express 应用需要实现一个核心路由,例如 /api/v1/format。这个路由接收 POST 请求,请求体中包含待格式化的代码和一些元数据,如语言类型。

下面是这个服务的完整实现 (server.js)。注意其中包含了生产级的实践,如结构化日志、详细的错误处理和优雅退出。

// server.js
const express = require('express');
const prettier = require('prettier');
const fs = require('fs');
const path = require('path');
const pino = require('pino');
const pinoHttp = require('pino-http');

// --- 配置与初始化 ---

// 1. 使用 Pino 进行结构化日志,这对于在 Kubernetes 中收集和分析日志至关重要
const logger = pino({
  level: process.env.LOG_LEVEL || 'info',
  transport: {
    target: 'pino-pretty', // 在开发环境中易于阅读
  },
});
const httpLogger = pinoHttp({ logger });

// 2. 定义 Prettier 配置文件的路径,这将由 Kubernetes ConfigMap 挂载
const PRETTIER_CONFIG_PATH = process.env.PRETTIER_CONFIG_PATH || '/etc/prettier/config.json';

// 3. 应用程序实例
const app = express();
const PORT = process.env.PORT || 3000;

let prettierOptions = {};

// --- 核心业务逻辑 ---

/**
 * 启动时加载 Prettier 配置
 * 这种方式避免了每次请求都去读取文件系统,提高了性能。
 */
async function loadPrettierConfig() {
  try {
    if (fs.existsSync(PRETTIER_CONFIG_PATH)) {
      const configContent = fs.readFileSync(PRETTIER_CONFIG_PATH, 'utf8');
      prettierOptions = JSON.parse(configContent);
      logger.info({ configPath: PRETTIER_CONFIG_PATH, options: prettierOptions }, 'Prettier configuration loaded successfully.');
    } else {
      logger.warn({ configPath: PRETTIER_CONFIG_PATH }, 'Prettier configuration file not found. Using default options.');
      // 可以在这里设置一个组织范围内的默认安全配置
      prettierOptions = { semi: true, singleQuote: true, trailingComma: 'es5' };
    }
  } catch (error) {
    logger.error({ err: error, configPath: PRETTIER_CONFIG_PATH }, 'Failed to load or parse Prettier configuration. Falling back to defaults.');
    // 即使配置加载失败,服务也应该能以默认配置启动,保证可用性
    prettierOptions = { semi: true, singleQuote: true, trailingComma: 'es5' };
  }
}

/**
 * 格式化代码的核心处理函数
 * @param {string} code - The source code to format.
 * @param {string} parser - The Prettier parser to use (e.g., 'typescript', 'json').
 * @returns {Promise<string>} - The formatted code.
 */
async function formatCode(code, parser) {
  // 合并基础配置和请求中指定的 parser
  const finalOptions = {
    ...prettierOptions,
    parser,
  };

  try {
    // Prettier 3.x 开始 format 是异步的
    const formattedCode = await prettier.format(code, finalOptions);
    return formattedCode;
  } catch (error) {
    // 这里的坑在于:Prettier 抛出的错误可能是语法错误,也可能是配置错误。
    // 我们需要区分并返回对客户端有用的信息。
    logger.warn({ parser, err: { message: error.message } }, 'Prettier formatting failed. This is likely a syntax error in the source code.');
    // 抛出一个自定义错误,以便在路由处理器中捕获并返回 400 状态码
    const clientError = new Error(`Formatting failed: ${error.message}`);
    clientError.statusCode = 400; // Bad Request
    throw clientError;
  }
}

// --- 中间件与路由设置 ---

// 使用 JSON 中间件处理请求体,并增加大小限制,防止恶意的大请求耗尽内存
app.use(express.json({ limit: '5mb' }));
app.use(httpLogger);

// 健康检查端点,Kubernetes Liveness/Readiness Probe 会用到
app.get('/healthz', (req, res) => {
  res.status(200).json({ status: 'ok', timestamp: new Date().toISOString() });
});

// 核心格式化 API
app.post('/api/v1/format', async (req, res, next) => {
  const { code, parser } = req.body;

  if (!code || typeof code !== 'string') {
    return res.status(400).json({ error: 'Field "code" is required and must be a string.' });
  }

  if (!parser || typeof parser !== 'string') {
    // 一个常见的错误是忘记提供 parser。Prettier 无法自动猜测所有语言。
    return res.status(400).json({ error: 'Field "parser" is required and must be a string (e.g., "typescript", "json", "css").' });
  }

  try {
    const formattedCode = await formatCode(code, parser);
    res.status(200).json({ formattedCode });
  } catch (error) {
    // 捕获 formatCode 中抛出的错误
    // 如果是已知客户端错误(如语法错误),返回 400,否则传递给通用错误处理器
    if (error.statusCode) {
      return res.status(error.statusCode).json({ error: error.message });
    }
    next(error); // 传递给下一个错误处理中间件
  }
});

// --- 错误处理与服务启动 ---

// 通用的 404 处理器
app.use((req, res) => {
  res.status(404).json({ error: 'Not Found' });
});

// 通用的错误处理器,捕获所有未处理的异常
// 这确保了任何服务器内部错误都不会导致敏感信息泄露
app.use((err, req, res, next) => {
  logger.error({ err, reqId: req.id }, 'An unexpected error occurred.');
  res.status(500).json({ error: 'Internal Server Error' });
});


const server = app.listen(PORT, async () => {
  await loadPrettierConfig();
  logger.info(`Formatting service listening on port ${PORT}`);
});

// --- 优雅退出 (Graceful Shutdown) ---
// 这在 Kubernetes 环境中至关重要。当 Pod 收到 SIGTERM 信号时,
// 它应该停止接受新请求,并等待现有请求处理完毕再退出。
function gracefulShutdown(signal) {
  logger.info(`${signal} received. Shutting down gracefully...`);
  server.close(() => {
    logger.info('HTTP server closed.');
    // 在这里可以添加其他清理逻辑,如关闭数据库连接等
    process.exit(0);
  });
}

process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

容器化应用:Dockerfile 最佳实践

接下来,我们需要为这个 Express 应用创建一个优化的、生产就绪的 Dockerfile。这里我们将使用多阶段构建(Multi-stage build)来减小最终镜像的体积,并增强安全性。

# Dockerfile

# --- STAGE 1: Builder ---
# 使用一个包含完整构建工具链的镜像来安装依赖,包括 devDependencies
FROM node:18-alpine AS builder

WORKDIR /usr/src/app

# 复制 package.json 和 package-lock.json
COPY package*.json ./

# 安装所有依赖,包括构建时需要的
# 使用 --only=production 会导致 prettier 无法作为 CLI 或 API 使用
# 我们需要完整的 node_modules
RUN npm install

# 复制应用源代码
COPY . .

# 如果有构建步骤(例如 TypeScript 编译),在这里执行
# RUN npm run build


# --- STAGE 2: Production ---
# 使用一个更精简的基础镜像来运行应用
FROM node:18-alpine

WORKDIR /usr/src/app

# 从 builder 阶段复制已经安装好的 node_modules
# 这是一个关键优化,避免了在生产镜像中再次运行 npm install
COPY --from=builder /usr/src/app/node_modules ./node_modules

# 只复制应用运行所必需的文件
COPY --from=builder /usr/src/app/server.js ./server.js
COPY --from=builder /usr/src/app/package.json ./package.json


# 创建一个非 root 用户来运行应用,这是安全最佳实践
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

# 暴露端口
EXPOSE 3000

# 定义容器启动时执行的命令
# 使用 ["node", "server.js"] 形式可以正确处理信号(如 SIGTERM)
CMD ["node", "server.js"]

这个 Dockerfile 的好处:

  • 小体积: 最终镜像不包含 devDependencies 和构建工具,体积显著减小。
  • 高安全性: 应用以非 root 用户运行,降低了容器逃逸的风险。
  • 构建缓存: COPY package*.jsonRUN npm install 分开,可以有效利用 Docker 的层缓存。

部署到 Kubernetes:编排与配置

最后一步是将我们的容器化应用部署到 Kubernetes 集群。我们将创建三个核心资源:ConfigMap, Deployment, 和 Service

1. ConfigMap:外化配置

我们将 Prettier 的规则定义在一个 ConfigMap 中。这使得我们可以独立于应用代码更新格式化规则。

# prettier-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: prettier-config
  namespace: cicd-tools
data:
  config.json: |
    {
      "semi": true,
      "singleQuote": true,
      "trailingComma": "all",
      "printWidth": 100,
      "tabWidth": 2,
      "arrowParens": "always",
      "endOfLine": "lf"
    }

2. Deployment:定义应用部署

Deployment 描述了我们期望的应用状态:运行多少个副本、使用哪个镜像、资源请求和限制、以及如何进行健康检查。

# prettier-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: prettier-service
  namespace: cicd-tools
  labels:
    app: prettier-service
spec:
  replicas: 3 # 生产环境中至少需要2-3个副本保证高可用
  selector:
    matchLabels:
      app: prettier-service
  template:
    metadata:
      labels:
        app: prettier-service
    spec:
      containers:
      - name: prettier-service
        # 替换成你自己的镜像仓库地址
        image: your-registry/prettier-service:1.0.0
        ports:
        - containerPort: 3000
        # 环境变量,告诉应用去哪里找配置文件
        env:
        - name: PRETTIER_CONFIG_PATH
          value: "/etc/prettier/config.json"
        # 资源限制对于保证集群稳定性至关重要
        resources:
          requests:
            cpu: "100m"
            memory: "128Mi"
          limits:
            cpu: "500m"
            memory: "256Mi"
        # 健康检查:Kubernetes 用来判断 Pod 是否健康以及是否准备好接收流量
        livenessProbe:
          httpGet:
            path: /healthz
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /healthz
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 5
        # 挂载 ConfigMap 为文件
        volumeMounts:
        - name: prettier-config-volume
          mountPath: /etc/prettier
          readOnly: true
      volumes:
      - name: prettier-config-volume
        configMap:
          name: prettier-config

这里的关键点:

  • replicas: 3 确保了服务的高可用性。
  • resources 定义了容器的资源请求和上限,防止它耗尽节点资源。
  • livenessProbereadinessProbe 让 Kubernetes 能够智能地管理 Pod 的生命周期,例如在应用无响应时自动重启。
  • volumeMountsConfigMap 中的 config.json 挂载到容器内的 /etc/prettier/ 目录下,与 server.js 中的 PRETTIER_CONFIG_PATH 环境变量对应。

3. Service:暴露服务

Service 为一组动态变化的 Pod 提供了一个稳定的网络入口。

# prettier-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: prettier-service
  namespace: cicd-tools
spec:
  selector:
    app: prettier-service
  ports:
    - protocol: TCP
      port: 80 # Service 暴露的端口
      targetPort: 3000 # Pod 容器的端口
  # 在集群内部使用 ClusterIP 即可,外部访问通过 Ingress
  type: ClusterIP

部署完成后,集群内的其他服务(如 Jenkins Runner Pod)就可以通过 http://prettier-service.cicd-tools.svc.cluster.local/api/v1/format 来访问这个 FaaS 平台了。

局限性与未来迭代路径

这个方案有效地解决了代码格式化在多团队、多项目环境中的一致性问题,但它并非完美。当前的实现存在一些局限,也为未来的迭代指明了方向。

首先,目前的架构只支持单一版本的 Prettier 和一套全局配置。对于需要兼容历史项目或者允许团队自定义规则的大型组织,这是一个明显的短板。一个可行的演进方向是支持多租户配置,比如 API 接收一个 config_id 或者直接在请求体中附带 Prettier 配置对象,由服务动态应用。支持多 Prettier 版本则更复杂,可能需要为每个主版本维护不同的 Deployment,并通过 API 网关进行路由分发。

其次,性能方面,对于数万行代码的超大文件,同步的 API 调用可能会阻塞 Express 的事件循环,影响服务的吞吐量。尽管我们设置了请求体大小限制,但优化路径依然存在。可以考虑引入一个基于消息队列的异步处理模式。API 接收到请求后,将任务推入队列并立即返回一个任务 ID,由一组后台 Worker Pod 消费队列并执行实际的格式化操作。客户端再通过任务 ID 查询结果。

最后,安全性层面。虽然服务部署在内网,但它执行的是从外部传入的代码字符串。尽管 Prettier 本身是安全的,但依赖链中任何一个环节的漏洞都可能构成风险。在更严格的安全环境中,可能需要将此服务运行在更强的沙箱(如 gVisor 或 Kata Containers)中,并对传入的代码进行更严格的静态分析,以防止潜在的恶意输入。


  目录