构建与 Code Review 集成的 OpenFaaS 按需 SSR 预览服务以支持 Recoil 应用


团队的前端项目状态管理全面转向 Recoil 后,UI 层的复杂度和动态性急剧上升。随之而来的一个棘手问题是 Code Review 效率的下降。单纯的代码审查无法直观地验证复杂的交互逻辑和状态变更,而要求 Reviewer 本地拉取分支、安装依赖、启动开发服务器的流程,对于每个 Pull Request 来说,都是无法接受的时间开销。静态站点预览(如 Storybook)能解决组件级别的展示问题,但无法覆盖我们应用中那些依赖全局状态、跨页面流转的核心用户场景。痛点已经非常明确:我们需要为每个 PR 自动生成一个与生产环境高度一致的、可交互的预览环境。

初步的构想是利用 CI/CD 流水线,在 PR 创建时自动构建并部署一个临时的、可公开访问的应用实例。这里的关键约束是成本和速度。为每个 PR 部署一个完整的、常驻的容器实例(例如 Kubernetes Pod)资源开销巨大,且绝大部分时间处于闲置状态。这引导我们走向了 Serverless 架构,特别是 OpenFaaS。其按需调用、自动缩容至零的特性,天然契合我们这种“低频、突发”的访问模式。

技术选型决策的核心权衡点如下:

  1. 渲染模式:必须是 SSR。 我们的生产环境采用服务器端渲染以优化首屏加载和 SEO。预览环境必须忠实复现这一行为,以尽早暴露 SSR 独有的水合(Hydration)错误或性能问题。
  2. 核心挑战:Recoil 与 SSR 的结合。 这是最大的技术难点。在服务器端,我们需要执行组件渲染,捕获 Recoil 的原子状态,并将其序列化注入到 HTML 响应中。在客户端,应用必须能读取这些初始状态来“水合”其 Recoil store,避免不必要的二次数据请求和页面闪烁。
  3. 部署方案:OpenFaaS。 相较于传统的容器编排,FaaS 提供了更优的资源利用率和管理简便性。我们可以为每个 PR 创建一个独立的函数,URL 路径直接映射 PR 编号。PR 关闭时,自动销毁对应函数即可。冷启动的延迟对于内部预览场景是完全可以接受的。

整个流程的设计思路清晰了:开发者推送代码到 PR -> GitHub Actions 触发 -> 构建包含 SSR 逻辑和前端静态资源的 Docker 镜像 -> 将该镜像部署为一个新的 OpenFaaS 函数 -> 将函数 URL 评论到 PR 中 -> Reviewer 点击链接进行审查 -> PR 合并或关闭 -> GitHub Actions 自动清理对应的 OpenFaaS 函数。

sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub / Actions
    participant Registry as Container Registry
    participant OF as OpenFaaS Gateway
    participant PR as Pull Request

    Dev->>GH: git push to PR branch
    activate GH

    GH->>GH: Trigger "Preview Deploy" Workflow
    GH->>GH: Build Docker Image (tag: pr-123)
    GH->>Registry: Push my-app-preview:pr-123
    activate Registry
    Registry-->>GH: Push success
    deactivate Registry

    GH->>OF: faas-cli deploy --image=... --name=preview-pr-123
    activate OF
    OF-->>GH: Deploy success
    deactivate OF

    GH->>PR: Post comment: "Preview URL: http://faas.example.com/function/preview-pr-123"
    deactivate GH

    participant Reviewer as Reviewer
    Reviewer->>PR: Opens PR, clicks link
    activate Reviewer

    Reviewer->>OF: GET /function/preview-pr-123
    activate OF
    OF->>OF: Cold start (if needed), invoke function
    OF-->>Reviewer: Return SSR HTML Response
    deactivate OF
    deactivate Reviewer

第一步:实现健壮的 Recoil SSR 渲染器

这是整个方案的技术核心。我们需要一个 Node.js 服务来处理请求,它能渲染 React 组件,并正确处理 Recoil 状态。这个服务将被打包进 OpenFaaS 函数中。我们选择 Express 作为底层 Web 框架,因为它轻量且生态成熟。

首先是服务器入口文件 handler.js,这是 OpenFaaS 的处理函数。

// handler.js
'use strict';

const express = require('express');
const app = express();
const path = require('path');
const { render } = require('./ssr-engine'); // 核心渲染逻辑

// 生产环境中, 静态资源由 Dockerfile 构建并复制到 dist/client 目录
// OpenFaaS 会将工作目录设置为 /home/app
const staticAssetsPath = path.join(process.cwd(), 'dist/client');
app.use(express.static(staticAssetsPath));

// 通用路由处理器, 捕获所有非静态资源请求并执行 SSR
app.get('*', async (req, res) => {
    try {
        const { html, recoilState } = await render(req.url);

        // 将渲染后的 HTML 和 Recoil 状态发送给客户端
        // 注意 recoilState 已经过序列化处理
        res.status(200).send(html);
    } catch (error) {
        console.error('SSR Rendering failed:', error);
        // 在真实项目中, 这里应该返回一个更友好的错误页面
        res.status(500).send('Server-side rendering error.');
    }
});

// OpenFaaS 要求导出 app
module.exports = app;

真正的魔法发生在 ssr-engine.js。这里的关键是:我们需要渲染两次。第一次渲染是为了让 Recoil 的 selector 和异步 atom effect 得以执行和填充状态。之后,我们捕获整个 Recoil store 的快照,并带着这个快照进行第二次、也是最终的渲染,同时将快照序列化后注入 HTML。

// ssr-engine.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { JSDOM } from 'jsdom';
import App from '../src/App'; // 你的 React 应用根组件
import { allAtoms } from '../src/state/atom-registry'; // 一个手动维护的 atom 列表

// 辅助函数: 用于捕获 Recoil store 的状态
const captureRecoilState = (node) => {
    const capturedState = new Map();
    // Recoil 的内部 API 并不稳定, 这里的 `Snapshot` 是一个概念性实现
    // 实际项目中可能需要更 hack 的方式或等待官方提供标准 API
    // 这里的实现思路是: 创建一个仅用于读取的 RecoilRoot, 并在其内部访问所有已知的 atom
    function StateCaptureComponent() {
        allAtoms.forEach(atom => {
            const value = useRecoilValue(atom);
            capturedState.set(atom.key, value);
        });
        return null; // 此组件不渲染任何 UI
    }

    renderToString(
        <RecoilRoot>
            {node}
            <StateCaptureComponent />
        </RecoilRoot>
    );
    
    // 将 Map 转换为可序列化的对象
    const serializableState = {};
    for (const [key, value] of capturedState.entries()) {
        serializableState[key] = value;
    }
    return serializableState;
};

// HTML 模板
const htmlTemplate = (reactHtml, recoilState) => `
<!DOCTYPE html>
<html>
<head>
    <title>SSR Preview</title>
    <link rel="stylesheet" href="/styles.css">
</head>
<body>
    <div id="root">${reactHtml}</div>
    <script>
        // 将服务端状态注入到 window 对象, 供客户端水合
        window.__RECOIL_STATE__ = ${JSON.stringify(recoilState).replace(/</g, '\\u003c')};
    </script>
    <script src="/bundle.js"></script>
</body>
</html>
`;


export const render = async (url) => {
    // 模拟浏览器环境, 因为某些组件库可能依赖 window 或 document
    const dom = new JSDOM();
    global.window = dom.window;
    global.document = dom.document;

    const appNode = <App location={url} />;

    // 第一次渲染以填充状态
    // 这里的坑在于: 必须确保所有异步的 Recoil selector 都已 resolve
    // 在真实项目中, 这可能需要复杂的异步流程控制
    console.log("Pre-rendering to populate Recoil state...");
    captureRecoilState(appNode);

    // 第二次渲染, 带着已填充的状态
    const recoilState = captureRecoilState(appNode);
    console.log("Captured Recoil State:", recoilState);

    // 最终渲染, 客户端将使用 recoilState 进行水合
    const finalHtml = renderToString(
        <RecoilRoot initializeState={({ set }) => {
            Object.entries(recoilState).forEach(([key, value]) => {
                const atom = allAtoms.find(a => a.key === key);
                if (atom) {
                    set(atom, value);
                }
            });
        }}>
            {appNode}
        </RecoilRoot>
    );

    return {
        html: htmlTemplate(finalHtml, recoilState),
        recoilState
    };
};

atom-registry.js 是一个看似简单但至关重要的文件。由于无法在运行时动态发现所有 atom,我们必须手动维护一个列表,以便在服务端遍历和捕获它们的状态。

// src/state/atom-registry.js
import { userState } from './userAtoms';
import { productListState } from './productAtoms';
// ... 导入你应用中所有需要 SSR 的 atom

// 这里的列表必须是全面的, 遗漏会导致状态丢失
export const allAtoms = [
    userState,
    productListState,
    // ...
];

最后,客户端入口 src/index.js 需要被修改以支持水合。

// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import { RecoilRoot } from 'recoil';
import App from './App';
import { allAtoms } from './state/atom-registry';

const container = document.getElementById('root');

// 从 window 对象读取服务端注入的状态
const initialRecoilState = window.__RECOIL_STATE__;

const root = ReactDOM.createRoot(container);

root.render(
    <RecoilRoot initializeState={({ set }) => {
        if (initialRecoilState) {
            console.log("Hydrating Recoil state from server...", initialRecoilState);
            Object.entries(initialRecoilState).forEach(([key, value]) => {
                const atom = allAtoms.find(a => a.key === key);
                if (atom) {
                    // 使用 `set` 函数来初始化 atom 的值
                    set(atom, value);
                }
            });
        }
    }}>
        <App />
    </RecoilRoot>
);

第二步:为 OpenFaaS 构建生产级 Docker 镜像

一个高效的 Dockerfile 对函数的性能和安全性至关重要。我们采用多阶段构建(Multi-stage build)来减小最终镜像的体积。

# Dockerfile

# ---- Stage 1: Build Frontend Assets ----
FROM node:18-alpine AS builder

WORKDIR /usr/src/app

# 复制 package.json 和 lock 文件并安装依赖, 以利用 Docker 的层缓存
COPY package*.json ./
RUN npm install

COPY . .

# 执行客户端构建, 生成静态资源
RUN npm run build:client

# ---- Stage 2: Build Server Handler ----
FROM node:18-alpine AS server_builder

WORKDIR /usr/src/app

COPY package*.json ./
RUN npm install --production # 只安装生产依赖

COPY . .

# 这里可以添加服务端代码的转译步骤 (如 Babel 或 TSC)
# RUN npm run build:server

# ---- Final Stage: Production Image ----
FROM node:18-alpine

WORKDIR /home/app

# 设置 OpenFaaS 需要的环境变量
ENV NPM_CONFIG_LOGLEVEL=error \
    NODE_ENV=production

# 复制生产依赖
COPY --from=server_builder /usr/src/app/node_modules ./node_modules
# 复制服务端代码
COPY --from=server_builder /usr/src/app/handler.js .
COPY --from=server_builder /usr/src/app/ssr-engine.js .
COPY --from=server_builder /usr/src/app/src ./src 

# 复制构建好的前端静态资源
COPY --from=builder /usr/src/app/dist/client ./dist/client

# 添加非 root 用户以增加安全性
RUN addgroup -S app && adduser -S -G app app
USER app

# OpenFaaS watchdog 会调用这个命令
# 我们使用 of-watchdog 的 http 模式, 并将请求转发给我们的 express app
ENV fprocess="node handler.js"

# 暴露 express 服务的端口
EXPOSE 3000

# 健康检查 (可选, 但推荐)
HEALTHCHECK --interval=5s --timeout=2s --retries=5 CMD [ "wget", "-q", "-O", "/dev/null", "http://localhost:3000" ] || exit 1

这个 Dockerfile 的关键点:

  • 多阶段构建: builder 阶段负责资源密集的前端编译,其产物被复制到最终镜像,而编译工具链本身则被丢弃。
  • 依赖优化: npm install --production 避免将开发依赖打包进最终镜像。
  • 安全性: 使用非 root 用户运行应用。
  • OpenFaaS 集成: fprocess 环境变量告诉 OpenFaaS 的 watchdog 如何启动我们的服务。

第三步:配置 OpenFaaS 与 CI/CD 流水线

现在我们将所有部分串联起来。首先是 OpenFaaS 的部署描述文件 stack.yml。这个文件在 CI 中动态生成或作为模板使用。

# stack.template.yml
provider:
  name: openfaas
  gateway: http://127.0.0.1:8080 # 在 CI 中替换为实际的 gateway 地址

functions:
  preview-pr-__PR_NUMBER__: # __PR_NUMBER__ 是一个占位符
    lang: dockerfile
    image: your-registry.com/previews/my-app:pr-__PR_NUMBER__
    handler: .
    annotations:
      # 为预览环境设置一个合理的超时时间
      com.openfaas.scale.zero: "true"
      com.openfaas.scale.zero-duration: "15m"
    environment:
      NODE_ENV: production
      # 其他可能需要的环境变量
      API_ENDPOINT: "https://api.staging.example.com"

最后是 GitHub Actions 的工作流文件,这是整个自动化的指挥中心。

# .github/workflows/preview-deploy.yml
name: Deploy PR Preview

on:
  pull_request:
    types: [opened, synchronize, closed]

jobs:
  deploy:
    # 仅在 PR 打开或同步时运行
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2

      - name: Login to Container Registry
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        id: docker_build
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}/preview:pr-${{ github.event.pull_request.number }}

      - name: Install faas-cli
        run: curl -sL https://cli.openfaas.com | sudo sh

      - name: Deploy to OpenFaaS
        env:
          OPENFAAS_URL: ${{ secrets.OPENFAAS_URL }}
          OPENFAAS_PASSWORD: ${{ secrets.OPENFAAS_PASSWORD }}
        run: |
          echo "${OPENFAAS_PASSWORD}" | faas-cli login --username admin --password-stdin
          
          # 动态生成 stack.yml
          cat <<EOF > stack.yml
          provider:
            name: openfaas
            gateway: ${OPENFAAS_URL}
          functions:
            preview-pr-${{ github.event.pull_request.number }}:
              lang: dockerfile
              image: ghcr.io/${{ github.repository }}/preview:pr-${{ github.event.pull_request.number }}
              handler: .
              annotations:
                com.openfaas.scale.zero: "true"
                com.openfaas.scale.zero-duration: "15m" # 15分钟无活动则缩容到0
          EOF
          
          faas-cli deploy -f stack.yml

      - name: Post comment to PR
        uses: actions/github-script@v6
        with:
          script: |
            const functionUrl = `${process.env.OPENFAAS_URL}/function/preview-pr-${github.event.pull_request.number}`;
            const commentBody = `🚀 Preview environment is ready: [${functionUrl}](${functionUrl})`;
            
            // 查找旧评论并更新, 避免刷屏
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            const oldComment = comments.find(c => c.body.includes("Preview environment is ready"));
            
            if (oldComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: oldComment.id,
                body: commentBody + `\n*Updated at: ${new Date().toISOString()}*`,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body: commentBody,
              });
            }
        env:
          OPENFAAS_URL: ${{ secrets.OPENFAAS_URL }}

  cleanup:
    # 仅在 PR 关闭时运行
    if: github.event.action == 'closed'
    runs-on: ubuntu-latest
    steps:
      - name: Install faas-cli
        run: curl -sL https://cli.openfaas.com | sudo sh

      - name: Remove from OpenFaaS
        env:
          OPENFAAS_URL: ${{ secrets.OPENFAAS_URL }}
          OPENFAAS_PASSWORD: ${{ secrets.OPENFAAS_PASSWORD }}
        run: |
          echo "${OPENFAAS_PASSWORD}" | faas-cli login --username admin --password-stdin
          faas-cli remove preview-pr-${{ github.event.pull_request.number }}

方案的局限性与未来展望

这套方案成功地解决了我们的 Code Review 效率问题,但并非没有权衡。最明显的是函数冷启动带来的首次访问延迟,通常在 5-10 秒之间。对于内部预览这可以接受,但通过预热策略或使用 OpenFaaS Pro 的 scale-to-one 功能可以进一步优化。

其次,Recoil 状态的捕获机制依赖于一个手动维护的 allAtoms 列表,这在大型项目中可能成为一个维护负担。理想的未来是 Recoil 官方能提供一个标准的、用于 SSR 状态序列化和反序列化的 API。

最后,CI/CD 的执行时间也是一个瓶颈。每次代码变更都需要完整的 Docker 构建和推送,耗时在 3-5 分钟。未来的优化方向将聚焦于 Docker 层缓存的精细化控制,以及探索更快的构建技术,例如 Buildpacks,来缩短从代码提交到预览可用的时间窗口。尽管存在这些待办事项,但这套集成方案已将我们的前端开发与审查流程提升到了一个新的效率水平。


  目录