基于绞杀者模式与Jenkins实现Express.js单体应用至Azure Functions的渐进式重构


一个维护了数年的Express.js单体应用,其业务逻辑已经盘根错节。/api/users/api/orders/api/products等数十个路由挤在同一个代码库中,任何微小的改动都可能引发回归测试的风暴。数据库模式的耦合更是让独立部署新功能成为奢望。技术栈的陈旧导致无法利用Serverless等现代云架构的弹性与成本优势。面对这种局面,全盘重写(Big Bang Rewrite)的风险是不可接受的,它意味着长达数月甚至一年的开发冻结,以及最终交付时可能与市场需求脱节的巨大不确定性。

因此,我们选择了一条更稳健的路径:采用绞杀者模式(Strangler Fig Pattern),通过一个代理层(Facade)逐步将流量从旧的Express单体应用迁移到新的Azure Functions服务上。这个过程不是一蹴而就的,而是功能模块级别的、可控的、可逆的渐进式重构。整个流程的自动化编排,则由Jenkins流水线来保证。

架构决策:为何是绞杀者模式而非直接重构?

在真实项目中,技术决策的核心是风险控制。直接在原代码库上进行大规模重构,无异于在飞行中更换引擎。每一次改动都直接影响主干,分支管理的复杂性呈指数级增长,测试的覆盖范围也难以保证。

绞杀者模式提供了一种解耦的思路。其核心是引入一个“绞杀者立面”(Strangler Facade),它作为所有外部请求的唯一入口。起初,这个立面会将所有请求无差别地代理到旧的单体应用。当我们准备好一个新的、独立的微服务(在此场景下是一个Azure Function)来替代单体中的某个功能时,我们就在立面层修改路由规则,将对应路径的请求精确地导向新服务。旧的功能代码可以暂时保留,甚至在必要时作为回滚方案。

graph TD
    subgraph "初始状态"
        Client_A[客户端请求] --> Facade_A{绞杀者立面};
        Facade_A -- "/api/users" --> Monolith_A[Express.js 单体];
        Facade_A -- "/api/orders" --> Monolith_A;
    end

    subgraph "迁移进行中"
        Client_B[客户端请求] --> Facade_B{绞杀者立面};
        Facade_B -- "/api/users" --> Monolith_B[Express.js 单体];
        Facade_B -- "/api/orders" --> NewService[Azure Function: Orders];
    end

    subgraph "迁移完成"
        Client_C[客户端请求] --> Facade_C{绞杀者立面};
        Facade_C -- "/api/users" --> NewService_Users[Azure Function: Users];
        Facade_C -- "/api/orders" --> NewService_Orders[Azure Function: Orders];
    end

这种方法的优势显而易见:

  1. 低风险:新旧系统并行运行,迁移过程对用户透明。出现问题可以立即在立面层将流量切回旧系统。
  2. 持续交付:团队可以专注于开发单个功能,并独立部署,无需等待整个重构完成。
  3. 即时价值:每迁移一个功能,就能立即享受到新技术栈(如Azure Functions的弹性伸缩、按需付费)带来的好处。

核心实现:旧系统、新服务与绞杀者立面

我们的技术栈包含三个关键部分:

  1. 旧单体应用 (Legacy Monolith): 一个典型的Express.js应用。
  2. 新微服务 (New Service): 使用Node.js编写的Azure Functions。
  3. 绞杀者立面 (Strangler Facade): 一个轻量级的Express.js应用,其核心是实现了适配器模式(Adapter Pattern)的路由代理。

1. 旧单体应用 (legacy-monolith-app)

这是一个简化但具代表性的Express单体,它处理用户和订单。注意,这里的代码并不完美,它模拟了真实世界中可能存在的紧耦合和副作用。

./legacy-monolith-app/src/server.js

const express = require('express');
const bodyParser = require('body-parser');

const app = express();
app.use(bodyParser.json());
const PORT = process.env.LEGACY_PORT || 3000;

// 模拟数据库
const db = {
    users: { '1': { id: '1', name: 'Legacy Alice', version: 1 } },
    orders: { '101': { id: '101', userId: '1', item: 'Legacy Book', amount: 50, version: 1 } }
};

// --- 用户模块 ---
app.get('/api/users/:id', (req, res) => {
    console.log(`[LEGACY] Received request for user ${req.params.id}`);
    const user = db.users[req.params.id];
    if (user) {
        res.json(user);
    } else {
        res.status(404).json({ error: 'User not found in legacy system' });
    }
});

// --- 订单模块 (待迁移) ---
app.get('/api/orders/:id', (req, res) => {
    console.log(`[LEGACY] Received request for order ${req.params.id}`);
    const order = db.orders[req.params.id];
    if (order) {
        res.json(order);
    } else {
        res.status(404).json({ error: 'Order not found in legacy system' });
    }
});

app.post('/api/orders', (req, res) => {
    console.log(`[LEGACY] Creating new order...`);
    const newOrder = { ...req.body, id: '102', version: 1 };
    db.orders[newOrder.id] = newOrder;
    res.status(201).json(newOrder);
});


app.listen(PORT, () => {
    console.log(`Legacy monolith app listening on port ${PORT}`);
});

这个应用是我们的重构对象,它运行在3000端口。

2. 新服务:Azure Functions (new-orders-service)

我们首先迁移订单模块。使用Azure Functions,我们可以为每个端点创建一个独立的、可伸缩的函数。

./new-orders-service/GetOrderById/index.js

// GetOrderById/index.js
const createResponse = (status, body) => ({ status, body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } });

module.exports = async function (context, req) {
    context.log('[AZURE_FUNC] GetOrderById function processed a request.');

    const orderId = context.bindingData.id;

    // 在真实项目中,这里会连接到新的数据库(如Cosmos DB)
    // 为了演示,我们使用一个简单的模拟
    const newDb = {
        '101': { id: '101', userId: '1', item: 'Refactored E-Book', amount: 55, version: 2 }
    };

    const order = newDb[orderId];

    if (order) {
        context.res = createResponse(200, order);
    } else {
        context.res = createResponse(404, { error: `Order ${orderId} not found in new service` });
    }
};

对应的函数配置 function.json

{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "req",
      "methods": [
        "get"
      ],
      "route": "v2/orders/{id}"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    }
  ]
}

注意,新的API路径是/v2/orders/{id},这是一种常见的版本控制策略,有助于在迁移过程中区分新旧API。

3. 绞杀者立面 (strangler-facade)

这是整个架构的核心。它是一个Express应用,但其主要职责是代理请求。我们使用一个配置文件来决定哪些路由应该被迁移。这里的适配器模式体现在,无论后端是单体还是Serverless函数,立面对外暴露的接口(/api/orders/:id)保持不变,它“适配”了后端的实现差异。

./strangler-facade/src/config.js

// 这个配置文件是动态的,可以通过CI/CD流程更新
// 来控制流量的切换
const routingConfig = {
    // 默认所有请求都代理到旧单体
    defaultTarget: process.env.LEGACY_API_URL || 'http://localhost:3000',
    
    // 定义迁移规则
    // key 是请求路径的正则表达式
    // value 是新服务的目标地址
    strangledRoutes: {
        // 将所有 /api/orders/* 的GET请求迁移到新的Azure Function
        '^/api/orders/\\w+$': {
            target: process.env.NEW_ORDERS_SERVICE_URL, // e.g., 'https://my-func-app.azurewebsites.net/api'
            method: 'GET',
            rewrite: (path) => path.replace('/api/orders', '/v2/orders') // 路径重写
        }
        // POST /api/orders 尚未迁移,将继续由defaultTarget处理
    }
};

module.exports = routingConfig;

./strangler-facade/src/server.js

const express = require('express');
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
const routingConfig = require('./config');

const app = express();
const PORT = process.env.FACADE_PORT || 8080;

// 设计模式:适配器模式
// 这个中间件就是一个适配器,它根据配置决定将请求适配到哪个后端系统
const dynamicRouter = (req, res, next) => {
    const matchedRule = Object.keys(routingConfig.strangledRoutes).find(pattern => {
        const regex = new RegExp(pattern);
        const rule = routingConfig.strangledRoutes[pattern];
        // 匹配路径和HTTP方法
        return regex.test(req.path) && req.method === rule.method;
    });

    if (matchedRule) {
        const rule = routingConfig.strangledRoutes[matchedRule];
        console.log(`[FACADE] Strangling request ${req.method} ${req.path}. Routing to NEW service.`);
        
        const options = {
            target: rule.target,
            changeOrigin: true,
            pathRewrite: { [req.path]: rule.rewrite(req.path) },
            // 必须修复body,特别是对于Azure Functions
            onProxyReq: fixRequestBody, 
            onError: (err, req, res) => {
                console.error('[FACADE] Proxy error:', err);
                res.status(502).json({ error: 'Bad Gateway', details: err.message });
            }
        };
        // 为这个特定请求创建一个代理实例
        return createProxyMiddleware(options)(req, res, next);
    }
    
    // 如果没有匹配的规则,则代理到默认的旧单体
    console.log(`[FACADE] No strangle rule matched. Routing ${req.method} ${req.path} to LEGACY service.`);
    const defaultProxy = createProxyMiddleware({
        target: routingConfig.defaultTarget,
        changeOrigin: true,
        onProxyReq: fixRequestBody,
        onError: (err, req, res) => {
            console.error('[FACADE] Legacy proxy error:', err);
            res.status(502).json({ error: 'Bad Gateway to Legacy', details: err.message });
        }
    });
    return defaultProxy(req, res, next);
};

// 所有流量都经过我们的动态路由适配器
app.use('/', dynamicRouter);

app.listen(PORT, () => {
    console.log(`Strangler Facade listening on port ${PORT}`);
    console.log('--- Current Routing Configuration ---');
    console.log(`Default Target (Legacy): ${routingConfig.defaultTarget}`);
    console.log('Strangled Routes (New):');
    console.log(JSON.stringify(routingConfig.strangledRoutes, null, 2));
    console.log('-----------------------------------');
});

这里的关键是dynamicRouter中间件。它遍历routingConfig.js中的规则,如果请求匹配了某个迁移规则,就动态创建一个代理指向新的Azure Function;否则,就使用默认代理指向旧的Express单体。这正是适配器模式思想的体现:将一个接口(客户端请求)转换成客户端所期待的另一个接口(多个异构后端服务)。

自动化编排:Jenkinsfile的角色

手动部署这三个服务并更新配置文件是繁琐且容易出错的。Jenkins流水线是实现这一过程自动化的关键。它不仅负责构建和部署,更重要的是,它能原子化地管理流量切换的配置。

一个典型的Jenkinsfile可能如下所示:

// Jenkinsfile
pipeline {
    agent any

    environment {
        // 从Jenkins凭据管理器获取敏感信息
        AZURE_CREDENTIALS = credentials('azure-service-principal')
        // Facade配置,可以由参数化构建动态提供
        LEGACY_API_URL = "http://legacy-monolith.internal-dns:3000"
        NEW_ORDERS_SERVICE_URL = "https://my-func-app-prod.azurewebsites.net/api"
    }

    stages {
        stage('Checkout') {
            steps {
                // 假设所有代码在一个monorepo中
                checkout scm
            }
        }

        stage('Build & Deploy Legacy Monolith') {
            // 这个阶段可能在每次有改动时运行,或者在迁移期间保持稳定
            when { expression { return false } } // 在此演示中跳过,实际项目中会按需构建
            steps {
                dir('legacy-monolith-app') {
                    sh 'npm install'
                    // 假设打包成Docker镜像并推送到ACR
                    sh 'docker build -t myregistry.azurecr.io/legacy-monolith:latest .'
                    sh 'docker push myregistry.azurecr.io/legacy-monolith:latest'
                    // 部署到Azure App Service或AKS
                }
            }
        }

        stage('Build & Deploy New Azure Function') {
            steps {
                dir('new-orders-service') {
                    sh 'npm install'
                    // 使用Azure Functions Core Tools或Azure CLI进行部署
                    sh 'func azure functionapp publish my-func-app-prod --javascript'
                }
            }
        }

        stage('Build & Deploy Strangler Facade') {
            steps {
                dir('strangler-facade') {
                    sh 'npm install'
                    
                    // 核心步骤:动态生成配置文件
                    // Jenkins在这里扮演了配置管理的角色
                    // 它将流水线中的环境变量写入到应用的配置文件中
                    echo "Dynamically generating routing config..."
                    sh """
                    cat > src/config.js <<EOF
                    const routingConfig = {
                        defaultTarget: '${env.LEGACY_API_URL}',
                        strangledRoutes: {
                            '^/api/orders/\\\\w+\$': {
                                target: '${env.NEW_ORDERS_SERVICE_URL}',
                                method: 'GET',
                                rewrite: (path) => path.replace('/api/orders', '/v2/orders')
                            }
                        }
                    };
                    module.exports = routingConfig;
                    EOF
                    """
                    
                    // 打包并部署Facade应用(例如,到另一个Azure App Service)
                    sh 'docker build -t myregistry.azurecr.io/strangler-facade:latest .'
                    sh 'docker push myregistry.azurecr.io/strangler-facade:latest'
                    // 更新部署
                }
            }
        }

        stage('Integration Test') {
            steps {
                script {
                    // 运行端到端测试,验证流量路由是否正确
                    // 1. 测试未迁移的路由,应命中旧系统
                    def userResponse = sh(script: "curl -s -o /dev/null -w '%{http_code}' http://facade-prod.my-app.com/api/users/1", returnStdout: true).trim()
                    if (userResponse != '200') {
                        error "Legacy route /api/users/1 failed! Expected 200, got ${userResponse}"
                    }
                    
                    // 2. 测试已迁移的路由,应命中新系统
                    def orderResponse = sh(script: "curl -s http://facade-prod.my-app.com/api/orders/101", returnStdout: true)
                    if (!orderResponse.contains("Refactored E-Book")) {
                        error "Strangled route /api/orders/101 failed! Did not receive response from new service."
                    }
                    
                    // 3. 测试未迁移的POST路由
                     def postResponse = sh(script: "curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d '{\"userId\":\"2\",\"item\":\"New Legacy Item\"}' http://facade-prod.my-app.com/api/orders", returnStdout: true).trim()
                    if (postResponse != '201') {
                        error "Legacy POST route /api/orders failed! Expected 201, got ${postResponse}"
                    }
                }
            }
        }
    }
}

这个Jenkinsfile清晰地展示了CI/CD在绞杀者模式中的核心作用:它不仅仅是构建和部署的工具,更是架构演进的执行引擎。通过参数化Jenkins Job,我们可以轻松地添加或移除strangledRoutes中的规则,从而实现对流量的精细化控制,甚至可以实现金丝雀发布——只将一小部分流量(例如基于header或cookie)导入新服务。

方案的局限性与未来展望

当前这套基于Express.js的绞杀者立面方案,虽然简单有效,但在生产环境规模扩大时会遇到瓶颈。它本身成为了一个新的单点,其性能和可用性直接决定了整个系统的上限。在真实的大型项目中,这一层通常会由更专业的API网关(如Azure API Management, Kong, Traefik)来承担,它们提供了更强大的路由策略、认证、限流、监控等功能。Jenkins流水线届时将不再是生成配置文件,而是通过API调用或声明式配置(如Kubernetes CRD)来更新API网关的路由规则。

另一个挑战是数据。当新旧服务需要访问和修改同一份数据时,问题变得异常复杂。这通常需要数据库层面的解耦策略,如引入事件溯源(Event Sourcing)模式,通过事件总线来同步新旧数据库之间的状态变更,或者采用数据同步管道。这已经超出了绞杀者模式本身,进入了分布式数据管理的范畴。

最后,绞杀者模式的最终目标是“杀死”旧的单体应用。当所有功能都迁移完毕,立面层的路由规则将全部指向新服务,此时旧的Express应用就可以安全下线。这个过程是漫长但稳妥的,它用增量、可控的工程实践,取代了高风险的革命式重构。


  目录