CatchAdmin PHP 后台管理框架 Logo CatchAdmin

用绞杀者模式渐进迁移遗留 PHP 到 Laravel

大爆炸式重写是工程团队所能做出的最有信心的决定,同时也是最糟糕的决定之一。这套说辞总是听起来很合理:现有系统难以变更,人人都承认它难以变更,于是暂停功能开发,从头认真重建,六个月后带着一个可维护的成果回来。而实际情况是,到了第四个月,业务需要一个旧系统仍能提供、新系统却还做不到的功能,重写要么带着半成品仓促上线,要么被悄无声息地放弃。于是需要维护的系统从一个变成了两个。

当被替换的对象不是基于某个旧框架时,这种风险还会进一步升高。从 Symfony 2 迁移到 Laravel 虽然不愉快,但至少脉络清晰:有控制器,有路由,有服务容器,可以把概念一一对应过去。而过程式单体什么也给不了:全局状态直接从 $_GET 和 $_POST 里读取,mysqli_query 调用与 HTML 交织在一起,header('Location: ...') 散落在业务逻辑各处,include 则作为副作用把半个应用拉进来。这里没有任何可以抓住的接缝,这正是人们得出"只能推倒重来"这一结论的原因。

其实还有另一条路:把 Laravel 放在旧应用前面,一次迁移一条路由,让过程式代码继续服务所有尚未触及的部分。这就是绞杀者模式(strangler fig pattern),得名于一种藤蔓——它缠绕在宿主树上生长,逐渐接管其承重角色,最终只留下一具中空的躯壳。关键在于,整个过程中,树在任何时刻都依然屹立不倒。

让 Laravel 成为前门

第一步是决定谁拥有传入的请求。答案必须是 Laravel,而且从第一天起就必须是 Laravel,即便此刻 Laravel 连一条路由都不处理。

Web 服务器以 Laravel 的 public 目录作为 document root。Laravel 无法识别的任何请求都会落到遗留应用入口。随着端点被逐一迁移,它便不再向下落,而客户端的 URL 从头到尾都不用变。

text
Request
   |
   v
Nginx  ->  Laravel public/index.php
                |
                +-- route matched?  yes  ->  Laravel handles it
                |
                +-- no  ->  fallback to legacy/index.php

在 Nginx 中,大致配置如下:

nginx
server {
    listen 80;
    server_name my-app.test;
    root /var/www/my-app/laravel/public;

    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location /legacy/ {
        root /var/www/my-app;
        try_files $uri $uri/ @legacy;

        location ~ ^/legacy/.+\.php$ {
            root /var/www/my-app;
            try_files $fastcgi_script_name =404;

            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
        }
    }

    location @legacy {
        rewrite ^/legacy/(.*)$ /legacy/index.php?page=$1 last;
    }

    location ~ ^/index\.php(/|$) {
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
    }
}

其中有两个细节值得专门说明,二者都曾耗费过大量排查时间。

第一点:legacy 块用的是 root 而不是 alias。当需要把 URL 前缀映射到 document root 之外的目录时,alias 似乎是顺理成章的选择,但它在 try_files 下并不工作。这是 nginx 的 97 号工单,自 2010 年起一直未关闭。alias 不会重写 $uri 的值,于是 try_files $uri 会把 location 前缀重新拼回去,去找 /var/www/my-app/legacy/legacy/whatever。它会静默失败,看起来像是 fallback 坏了,而不是路径出了问题。如果能把目录安排成"遗留应用位于共同父目录之下"的布局,root 就能绕开整个问题;如果做不到,就改用带捕获组的正则 location,自己拼接路径,而不是依赖 $uri。

第二点:PHP 块中的 try_files $fastcgi_script_name =404,以及 Laravel 一侧收窄后的 ^/index.php(/|$) 匹配模式。裸的 location ~ .php$ 会把一切以 .php 结尾的请求都交给 FPM,这正是长期以来众多远程代码执行报告的根源:请求 /uploads/avatar.jpg/x.php 最终可能执行到上传的文件。遗留应用通常把上传目录放在 web root 之内,而这恰恰正是该问题最要紧的情形。

另一种方案——也是当遗留 URL 结构混乱到让服务器配置难以阅读时人们通常会选用的方案——是在 Laravel 内部用一条 catch all 路由完成 fallback,代理到旧应用。它更慢,因为每个遗留请求在交接给旧应用之前都要先启动框架,但它把路由表放进了版本控制,可以阅读、可以测试、可以记录日志。在流量可观的系统上,头几个月这种取舍通常值得做,之后再把它改回来。

无论哪种方式,想要的属性是一致的:只有一处决定请求的去向,而迁移一个端点,就是在那一处改一行。

真正卡住人的环节:会话

路由很简单。认证才是这类迁移停滞的地方。

过程式应用调用 session_start() 并向 $_SESSION['user_id'] 写入数据。Laravel 有自己的一套会话处理、自己的 cookie、自己的加密,以及自己关于"什么是已认证用户"的定义。如果什么都不做,通过旧系统登录的用户访问已迁移的 Laravel 路由时,会被弹回登录表单。登录两次不是迁移,而是多了几步操作的宕机。

最常见的建议是让两个应用共享同一个会话存储。这里要说的是:不建议这么做,因为它比听上去难得多,而且原因并不显而易见。

PHP 并没有自带数据库会话处理器。session.save_handler 可接受的值是 files、redis、memcached 和 user,而 user 意味着要写一个实现 SessionHandlerInterface 的类,并用 session_set_save_handler() 注册它。于是第一步,就得在本来希望完全不动的代码库里新增一个定制类。

第二步更糟。Laravel 的 database 会话驱动并不存储原生 PHP 会话 blob。它在 payload 列中存的是 base64_encode(serialize($attributes)),除此之外还并列存有 id、user_id、ip_address、user_agent 和 last_activity。而且 Laravel 并不会因为 payload 某处存在 user_id 就认为用户已登录:SessionGuard 查找的键由 login_ 加上 guard 名再加上 guard 类的 SHA1 组成,对默认的 web guard 来说,就是一个形如 login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d 的字符串。要让遗留处理器产生 Laravel 接受的会话,就必须精确复现那种格式和那个键。这件事做得成,但它绝非表面上看去的小机械改动,而且会把旧应用耦合到不属于公共 API 的 Laravel 内部实现上。

所以说实话只有两条真正可行的路。

第一条,也是如今默认会采用的一条,是把问题倒过来:把认证作为最先迁移的东西。由 Laravel 掌管登录、注销和会话,遗留应用改为读取 Laravel 写入的内容。这比自定义 save handler 对旧代码库的改动更小:检查 $_SESSION['user_id'] 的过程式应用,可以改指到一个读取 Laravel 会话或签名 cookie 的共享函数。这种别扭的工作只在开头做一次,之后每一次迁移都能继承一个可用的认证上下文。

第二条,适用于登录功能确实暂时无法迁移的情况:在中间件里自行读取遗留会话。

php
namespace App\Http\Middleware;

use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;

final class BridgeLegacySession
{
    public function handle(Request $request, Closure $next): Response
    {
        if (Auth::check()) {
            return $next($request);
        }

        $cookie = config('legacy.session_cookie', 'PHPSESSID');

        if (! $request->hasCookie($cookie)) {
            return $next($request);
        }

        $session = DB::connection('legacy')
            ->table('sessions')
            ->where('id', $request->cookie($cookie))
            ->first();

        if (! $session) {
            return $next($request);
        }

        $userId = $this->extractUserId($session->data);

        if ($userId && $user = User::find($userId)) {
            Auth::login($user);
        }

        return $next($request);
    }

    private function extractUserId(string $data): ?int
    {
        $vars = [];
        $offset = 0;

        while ($offset < strlen($data)) {
            $delimiter = strpos($data, '|', $offset);

            if ($delimiter === false) {
                break;
            }

            $name = substr($data, $offset, $delimiter - $offset);
            $offset = $delimiter + 1;

            $value = unserialize(substr($data, $offset));

            $vars[$name] = $value;
            $offset += strlen(serialize($value));
        }

        return $vars['user_id'] ?? null;
    }
}

这个解析器之所以存在,是因为 PHP 默认的会话序列化格式与 serialize() 的输出并不相同。它是一串 name|serialized_value 对,关键在于 name 不携带长度前缀,因此不能把整个字符串直接交给 unserialize(),只能改为逐字节地遍历。

要清楚这种方案的脆弱之处,因为最直观的猜测恰恰是错的。会话值内部的管道符没有问题:值经过正确序列化,循环按 strlen(serialize($value)) 前进,而不是去寻找下一个分隔符。真正会搞坏它的,是会话键里的管道符——这合法,而且旧代码库里没有任何东西阻止别人写出这样的键。另一个薄弱点是:对目标值之后还拖着一截数据的子串调用 unserialize()。这能工作,但 PHP 对此抱怨的激烈程度随版本而变,所以要针对实际部署的版本检查行为,而不是轻信任何人的既有经验。

还有第三种无法绕过的失败模式:如果会话里存的是序列化对象,而该对象的类在 Laravel 应用里不存在,unserialize() 会返回 __PHP_Incomplete_Class,任何下游触及它的代码都会抛错。

所以,如果确实要写,就把 unserialize() 调用包进错误处理,高调记录每次失败,并带上 session id,并把它当作注定要删除的临时桥梁,而不是打算长期保留的基础设施。在 bootstrap/app.php 中把它注册到 web 组,每一条已迁移的路由就会自动获得填充好的 $request->user()。

另外,在遗留应用中要显式设置 session.serialize_handler,而不是想当然。默认值是 php,正是上面解析器所预期的,但需要这种迁移的代码库通常足够老,老到可能有人早已出于无人记得的原因,把它改成了 php_binary。

让 Eloquent 对接并非自己设计的表结构

过程式代码库有自己的一套命名习惯,而这些习惯通常是某个人 2011 年的个人体系:表名带 tbl_ 前缀,主键叫 usr_id_pk,时间戳以 unix 整数形式存在名为 dt_created 的列里,deleted 标志有时是 0、有时是 NULL,取决于哪条脚本写入了该行。

人的本能是先修数据库结构。不要这么做。结构迁移要放到最后而不是最先,因为整个迁移期间两个应用都在读写同一批表。Eloquent 完全乐于被告知各列的命名:

php
namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

final class User extends Authenticatable
{
    protected $connection = 'legacy';

    protected $table = 'tbl_users';

    protected $primaryKey = 'usr_id_pk';

    public const CREATED_AT = 'usr_created_dt';

    public const UPDATED_AT = 'usr_updated_dt';

    protected $fillable = [
        'usr_email',
        'usr_password',
        'usr_name',
    ];

    protected function casts(): array
    {
        return [
            'usr_created_dt' => 'timestamp',
            'usr_updated_dt' => 'timestamp',
        ];
    }

    public function getAuthPassword(): string
    {
        return $this->attributes['usr_password'];
    }
}

这些 casts 不是可选的装饰。通过 CREATED_AT 和 UPDATED_AT 命名列,只是告诉 Eloquent 写入时该动哪些列,却并没有告诉它如何读取。如果遗留应用存的是 unix 整数而不是 datetime 类型,就需要 timestamp cast,否则写出的每个日期比较都是在拿字符串去和一个整数做运算,悄无声息地产出荒谬结果。

遗留表上还有两件事要检查。如果主键是字符串,或是以 char 列存储的 UUID,就把 $incrementing 设为 false、$keyType 设为 'string',因为 Eloquent 默认假设自增整数主键,会在你毫无察觉时把主键强转掉。如果表根本没有时间戳列——这在查找表和关联表中很常见——就把 $timestamps 设为 false,否则每次写入都会在并不存在的列上失败。

还可以更进一步,把命名完全藏进 accessor,这样 Laravel 其余代码永远不知道 usr_email 的存在:

php
use Illuminate\Database\Eloquent\Casts\Attribute;

protected function email(): Attribute
{
    return Attribute::make(
        get: fn (): string => $this->usr_email,
    );
}

对最常触及的列,这么做是值得的:当最终重命名底层列的那一天到来,只需改一个 accessor,而不是三百处调用点。模型由此成为继承下来的表结构与真正想要的领域语言之间的翻译层。

关于密码有一个注意事项,单靠 getAuthPassword() 并不能解决。遗留应用使用当年流行的任何算法做哈希,而这种算法常常是 MD5 或不加盐的 SHA1。面对这类哈希,Laravel 的 Hash::check() 不会返回 false,而是直接失败,因为它会把 bcrypt 哈希字符串交给 password_verify()。需要自行识别遗留格式并亲自比较:

php
if (str_starts_with($user->getAuthPassword(), '$2y$')) {
    $valid = Hash::check($plain, $user->getAuthPassword());
} else {
    $valid = hash_equals($user->getAuthPassword(), md5($plain));
}

if ($valid && Hash::needsRehash($user->getAuthPassword())) {
    $user->forceFill([
        'usr_password' => Hash::make($plain),
    ])->save();
}

用 hash_equals() 而不是普通比较,以免泄露时序信息;并在每次登录成功后重新哈希,让用户回归时无感迁移到 bcrypt。几个月后,遗留分支几乎覆盖不到任何人,就可以强制剩余的少数用户重置密码,然后把这段代码删掉。注意,这会重写旧应用仍在读取的密码列,所以在开启这段逻辑之前,遗留登录代码必须能识别 bcrypt。这个顺序极易弄反,一旦弄反,就会把用户锁在门外。

迁移什么,按什么顺序

入口、会话和模型就位之后,绞杀就可以开始了。顺序比速度更重要。

先从没有用户界面的东西入手:定时任务、webhook 接收器、报表导出——凡是跑在 cron 上并写表的内容。它们的输入输出清晰,没有会话状态,也没有视觉回归风险。把其中一件重写成 Artisan 命令,一个下午就能完成,而且能在任何面向用户的功能依赖它之前,验证整个管道是通的。

然后处理只读页面。列表页或详情页风险低,因为出错立刻可见,回滚也干净。这里也正是为后续迁移确立模式的地方,所以值得花时间把结构做对,而不是赶进度。

接下来是写入路径,而且要一次一条:创建订单的表单、结账流程、设置更新。业务逻辑集中在这里,也就意味着未文档化的行为同样集中在这里。在重写之前,要认真阅读旧代码,把发现的每个副作用都记下来,包括那些看起来像 bug 的。有些确实是 bug;有些则是承重结构,区分二者的方式是和业务方沟通,而不是独自拍板。

视图放到最后,更准确地说,视图是伴随已迁移内容自然发生的。把 include 'header.php' 换成 Blade 布局并不是一个独立项目,而是每个页面迁移时顺理成章的事。

人人都跳过的,是最后一步。当一条路由完全由 Laravel 接管后,删除遗留文件,移除它的 fallback 指令。如果不删,它就留在那里,十八个月后没人能确定它是否仍可访问。删除,是迁移完成的唯一证据。

如何判断迁移是否在起作用

没有度量方式的绞杀式迁移会永远跑下去,因为总有什么比下一块切片更紧急。两个数字能让它保持诚实。

第一个是仍然命中遗留 fallback 的请求数。记录它、画成图,把图放在团队能看到的地方。一条趋向于零的曲线,是有史以来最有说服力的迁移状态报告。

第二个是剩余遗留文件的数量。它很粗糙,文件大小参差不齐,但它只朝一个方向移动,而且无可争辩。

这个模式之所以有效,不是因为技术上的优雅,而是因为它从不要求业务方接受一段什么都没有交付的时期。每周都交付一点东西,每周旧系统都少一点。没有人需要咬牙坚持六个月,因为距离下一个可见的改进,从来不会超过一周。

整个诀窍就在这里:不是英雄式的重写,而是缓慢、枯燥、毫不停歇地把旧代码仍被允许做的事一点点收窄。

本作品采用《CC 协议》,转载必须注明作者和本文链接