一个维护了数年的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
这种方法的优势显而易见:
- 低风险:新旧系统并行运行,迁移过程对用户透明。出现问题可以立即在立面层将流量切回旧系统。
- 持续交付:团队可以专注于开发单个功能,并独立部署,无需等待整个重构完成。
- 即时价值:每迁移一个功能,就能立即享受到新技术栈(如Azure Functions的弹性伸缩、按需付费)带来的好处。
核心实现:旧系统、新服务与绞杀者立面
我们的技术栈包含三个关键部分:
- 旧单体应用 (Legacy Monolith): 一个典型的Express.js应用。
- 新微服务 (New Service): 使用Node.js编写的Azure Functions。
- 绞杀者立面 (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应用就可以安全下线。这个过程是漫长但稳妥的,它用增量、可控的工程实践,取代了高风险的革命式重构。