ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

双框架共存:ThinkPHP与Laravel在码头货柜管理系统的整合实践

双框架共存:ThinkPHP与Laravel在码头货柜管理系统的整合实践 接手这套码头船只货柜管理系统的时候我先在代码仓库里看到两个 PHP 框架老的业务后台是 ThinkPHP新的接口和作业模块是 Laravel。第一反应是“这以后怎么维护”但代码不会说谎——这套系统就是一个边用边改的老项目一步步长成这样的。码头业务没停过船只靠离泊、货柜装卸、堆场翻箱、闸口进出每天都有数据在产生。系统要管的是一只货柜从下船到离场的完整轨迹还要对接外部单位的报文和数据查询。用两个框架不是哪个架构师拍脑袋定的而是历史包袱和新需求在同一个项目里共存的结果。这篇文章不画那种漂亮的架构图我把自己在做双框架整合、数据模型设计、闸口高并发处理和排障过程中实际用到的方案、踩过的坑、推荐的配置都写出来。正在做物流、港口、仓储这类传统行业信息化或者遇到 PHP 项目框架迁移的同行可以直接拿去参考。1. 为什么一个货柜管理系统会同时用两个PHP框架1.1 项目背景一套系统里的“两代人”这套码头船只货柜管理系统最早跑的是 ThinkPHP版本停在 5.x覆盖的是基础台账船期表、货柜列表、费用结算、给理货公司用的报表查询。功能谈不上多花哨但胜在稳定老业务人员早就用顺手了每天上班第一件事就是打开后台看今天的船舶计划。后来需求变了。司机要一个小程序查提柜状态闸口要上扫码枪自动登记海关和船公司那边要求提供集装箱状态查询接口现场作业还要一块实时更新的堆场看板。这些需求有两个共同特点一是要响应快二是要和外部系统做数据交互。老框架写接口也能写但中间件、参数校验、队列、任务调度这些能力都得自己拼拼出来的东西维护成本很高。新团队接手时选型很自然就挑了 Laravel。它的迁移、队列、调度、REST API 支持都比较完整生态里现成的东西多。于是项目变成了一个仓库里装了两套 PHP 应用ThinkPHP 管老业务Laravel 管新作业。这套组合看起来奇怪但在不少传统行业信息化项目里你都能看到类似的影子——不是技术选型有多完美而是业务把人推到了这一步。1.2 不直接重写老框架的理由很多同行第一反应肯定是都这样了干脆把 ThinkPHP 全部重写成 Laravel 算了。我当时也动过这个念头还认真列过迁移清单。但真正上手之后发现这个设想太理想化了。码头业务不允许你停下来把旧系统翻新再上线。船只靠泊计划早就排到几个月后堆场里全是真实货物货柜状态和账目每天都在变数据源还牵扯到海关申报、船公司 EDI 报文。重写意味着把十多年的业务逻辑在同一套表结构上再实现一遍中途只要出现一次对账差异就是现场事故。这种场景下最稳妥的路径不是重写而是让新旧模块并行先把新增需求用更合适的框架做稳再把老模块逐步接口化、模块化。双框架共存的本质是一个受控的过渡方案只不过这个过渡期比想象中长。1.3 职责边界不是两套一样重的东西双框架项目最怕的是职责边界不清。我的做法是把业务按“稳定”和“变化”分开稳定且历史包袱重的留下来变化快、交互多的交给新框架。页面、权限、主数据这种内核不动接口、作业流、外部对接优先走 Laravel。业务模块所在框架选择原因船期管理、基础台账、老报表ThinkPHP历史沉淀核心表结构稳定重写风险高闸口进出、堆场作业、扫码接口Laravel新需求需要实时状态流转和接口文档支持外部系统对接、EDI 报文处理Laravel中间件、队列、签名校验更顺手定时统计、费用汇总Laravel 为主调度框架成熟老系统只做兜底输出这张表后来贴在了项目 wiki 的第一页。代码里可以有两套框架但业务上谁负责什么必须清晰。写操作尽量收敛到同一层查询可以放开但任何变更都要能追溯到发起方。这个原则在后面的数据模型设计里体现得很直接。2. 货柜管理的核心业务模型与数据表设计2.1 码头业务里绕不开的几个对象货柜码头和普通仓库最大的区别是所有操作都围绕“船”和“箱”两个实体展开而且状态变化频繁。船舶到港前要先建好船次信息到港后根据装卸计划把箱子从船上卸下来拖到堆场指定位置客户来提柜时集卡从闸口进扫箱号、验提柜单然后出闸。这里面的核心对象有四个voyages船次表船名、航次、泊位、预抵时间、预离时间。containers货柜表箱号、箱型20GP/40GP 等、空重状态、当前位置、当前状态。gate_logs闸口记录每辆集卡进出码头的扫码流水。loading_plans装卸计划把船、货柜、装卸动作关联在一起的作业计划。货柜表里的“当前状态”是业务命脉比如重箱、空箱、在场、已提、已装船。但一个字段远远不够因为状态本身是过程不是一个静态点。只记当前状态谁也说不清这只箱子是何时变的、从哪变到哪、是谁操作的。所以从第一天开始我就坚持状态表要有一个可追朔的流水这就是 container_move_log 的来历。2.2 状态流转记录让每一只货柜的轨迹可追溯container_move_log 是整个系统里我最重视的一张表任何状态变化都必须在这里留下痕迹。字段设计比较克制但每个字段在排障时都派上过用场CREATE TABLE container_move_log ( id bigint unsigned NOT NULL AUTO_INCREMENT, container_no varchar(20) NOT NULL COMMENT 箱号, action varchar(30) NOT NULL COMMENT 动作编码, from_location varchar(30) DEFAULT NULL COMMENT 原位置, to_location varchar(30) DEFAULT NULL COMMENT 目标位置, source_system varchar(10) NOT NULL COMMENT tp/laravel, request_id varchar(40) DEFAULT NULL COMMENT 关联请求ID, operator varchar(50) DEFAULT NULL COMMENT 操作人, created_at datetime NOT NULL, PRIMARY KEY (id), KEY idx_container_no (container_no), KEY idx_action_created (action, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;特别说一下 source_system 字段。因为两个框架都会往这张表写数据没有来源标记出了问题根本不知道是哪个模块干的。加了这个字段之后统计哪个链路丢数据、哪个动作没写流水一眼就能看到。这也是双框架项目里我最推荐的做法所有共享表都留一个来源标记。request_id 也很关键。它不是组件自带的能力而是我在公共入口生成的一段随机 ID从请求进来到最终写库都带上它。跨框架排查时按 request_id 一拉就能串起整条链路省掉大量“这个数据到底是谁写的”的口水仗。2.3 闸口高并发Redis预占锁与幂等处理闸口扫码是典型的并发场景。集卡司机把提柜单递给闸口员闸口员用扫码枪扫一下箱号系统要同时做三件事把货柜状态从未提变成已提、生成一条出场记录、释放堆场位置。高峰期一条闸口一小时要处理几百次操作看起来量不大但同一个箱号完全可能被两个窗口同时扫到。一旦处理重了轻则重复收出场费重则把货柜状态搞乱。我的做法是双保险Redis 预占锁加数据库幂等检查。锁的值必须是一个随机串释放前先比对防止误删别人刚抢到的锁$lockKey gate:lock: . $containerNo; $lockValue uniqid(, true); if (!Redis::set($lockKey, $lockValue, [NX, EX 10])) { throw new BizException(货柜正在操作中请勿重复提交); } try { // 幂等检查如果出场记录已存在直接返回 // 更新货柜状态写入 container_move_log // 更新堆场位置 } finally { if ((string) Redis::get($lockKey) $lockValue) { Redis::del($lockKey); } }这个锁方案看起来简单但很多人容易在释放那一步翻车——直接用 del 把锁删了导致另一个请求刚拿到的锁被误删。这里必须比对值再删锁值用随机串而不是固定字符串。这也是我在实际项目里被坑过之后才总结出来的。3. 让两个框架在一个项目里和平共处3.1 目录、入口与Nginx路由的物理层隔离双框架项目首先要解决物理共存问题。我的布局是一个仓库两个应用根目录tp_app 和 laravel_app 各自独立入口文件也各自独立。Nginx 按路径把请求分发到不同入口基本形态长这样server { listen 80; server_name tms.example.com; root /data/www/tms; # ThinkPHP 老后台 location /admin { alias /data/www/tms/tp_app/public; try_files $uri $uri/ /tp_app/public/index.php$is_args$args; } # Laravel API 和作业模块 location /api { alias /data/www/tms/laravel_app/public; try_files $uri $uri/ /laravel_app/public/index.php$is_args$args; } }这只是最简版本生产环境还要加上 PHP-FPM 转发、静态资源缓存这些。这样一个请求走到 /api 就由 Laravel 处理走到 /admin 就由 ThinkPHP 处理两者互不干扰。还有一个容易忽略的问题老框架当时跑在 PHP 7.2 上新应用要求 PHP 8.1 以上。我直接在一台机器上挂了两个 php-fpm 实例Nginx 按 location 分别转发。发布时各自独立操作互不影响。3.2 数据库、时区与模型层的统一规范两边代码可以不一样但数据库必须统一。字符集必须是 utf8mb4否则中文和特殊字符容易出问题排序规则也要一致不然 join 查询会报错。数据库连接串里的 time_zone 我也统一设置了避免哪个连接拿到系统默认时区。这里有个特别容易踩的坑时区不一致。ThinkPHP 那边通常写的是固定东八区Laravel 默认按 UTC 处理两边往库里写 created_at同一个动作可能差八个小时。我们一开始没注意后来做统计报表时发现同一个闸口动作在两套接口里显示的时间对不上查了半天才发现是时区问题。最后处理办法很直接Laravel 的 config/app.php 里 timezone 明确改成 Asia/Shanghai数据库连接参数里的 time_zone 也设为 08:00两边 ORM 的时间写入统一用数据库当前时间不让框架各自生成。从源头掐断比事后换算靠谱得多。3.3 登录态互通把PHP session请到Redis里面早期系统有个问题用户在 ThinkPHP 老后台登录之后跳到 Laravel 新模块又要重新登一次。原因是 Laravel 默认的 session cookie 名是 laravel_sessionThinkPHP 则依赖 PHPSESSID两者互相不认识。解决方案是统一路口两边 session 驱动都改成 Rediscookie 名统一成同一个cookie 的 domain 和 path 也保持一致。具体来说ThinkPHP 的 session 配置和 Laravel 的 .env 里要指向同一个 Redis 库、同一个 key 前缀// ThinkPHP config/session.php return [ type redis, name tms_session, expire 480, prefix sess:, ];# Laravel .env SESSION_DRIVERredis SESSION_COOKIEtms_session SESSION_LIFETIME480 CACHE_PREFIXsess:改完配置之后记得清掉浏览器 cookie 和 Redis 里残留的旧 session key不然本地登录会一直失败你还以为是配置写错。这个操作我给项目组写进了发布手册出现过太多次了。3.4 队列、定时任务与日志格式的标准化老代码里有些异步任务是用 think\queue 消费的新任务则走 Laravel 队列。为了避免两套 worker 抢消息我在 Redis 里给两边的队列 key 加了不同前缀业务上互不干扰。新任务一律走 Laravel 队列老任务不强制迁移但也不扩展。日志更需要提前统一。两个框架各自记录错误日志格式还不一样线上排查会变成灾难。我做的第一件事是把日志格式标准化成一行一条 JSON并且每一行都带上 request_id。Laravel 本身支持 json 格式日志ThinkPHP 老框架我不动它的核心在公共入口加了一个文件日志处理器输出同样的结构。这样按 request_id 一搜从老后台入口到 Laravel API 的所有操作全部串联起来排障效率能提高几倍。4. 双框架项目排障实录那些让你挠头的坑4.1 Composer自动加载与OPcache缓存带来的“诡异”报错新老代码放在同一个仓库里正常情况下 php-fpm 进程是隔离的不应该出现类冲突。但有段时间每次发布完老后台就随机报“Class not found”刷新一下可能又好了。这种问题最折磨人因为它不稳定复现。后来定位到是 OPcache 的问题。两个应用各有自己的 vendor 目录但 OPcache 在缓存类映射时不会按应用严格区分发布时如果只更新了文件而没有重置缓存就可能出现类路径错乱。解决办法也简单发布脚本里加上 OPcache 清理或者重启 php-fpm。另外要提醒的是两个项目各有独立 composer.json 和 vendor 目录Composer 本身不会跨项目加载对方的类但如果你在代码里手动 require 了跨项目路径的文件就很容易出现类名覆盖。不用问我是怎么知道的。4.2 Redis缓存前缀没隔离引发的会话串号这个坑比上一个严重得多。某天上线后客服接到司机投诉说登录后台看到的是别人的账号。我们第一反应是权限系统被黑了赶紧查登录日志结果发现登录记录都正常。查到最后问题出在 Redis key 前缀。当时为了统一 session我把两边的缓存前缀改成了同一个但没想到 ThinkPHP 的某些缓存处理和 Laravel 的缓存处理对 key 的解析方式不完全一样。上线后 Redis 里出现了互相覆盖的 session 键导致用户会话串号。从那以后我定了一个死规矩同一个 Redis 实例里所有缓存 key 必须按应用分隔比如 tp:、laravel: 前缀同一类数据绝不裸奔。这个原则现在所有项目都沿用。4.3 时区不一致两个框架记录的created_at差了8小时这个在前面提过但值得单独说一次因为它特别隐蔽。我们做日报表时发现ThinkPHP 统计的日进场量和 Laravel 统计的日进场量对不上最后逐条对比才看到同一个动作在两套接口写入的记录创建时间差了整整八个小时。时区问题的可怕之处在于它的影响不是马上爆发的而是等你做聚合、做统计的时候才冒出来。等发现问题历史数据已经被污染了只能靠脚本按逻辑修正。所以我的建议是业务开发前就把时区定死不要依赖框架默认值写入数据库的时间统一走数据库当前时间。像这种基础设施层面的决策越早定越好。4.4 一次跨框架数据不一致的完整排查链路最后记录一个典型事故。某天作业经理反馈堆场看板显示一只 40HQ 重箱还在 B12 位但闸口出场流水明明已经显示这箱被提走了。这个级别的数据错乱现场箱管急得跳脚。我按顺序排查先查 gate_logs出场记录存在说明 Laravel 侧的闸口模块处理成功了。查 containers 表状态字段已经是“已提”但 position 还停在 B12。查 container_move_log发现只有“出场”动作没有“释放堆场位置”的记录。再查代码发现释放堆场位置的操作不在闸口主流程里而是丢给了一个异步队列任务而那个任务用的还是老的 think\queue 通道。任务在消费时抛了异常重试几次后停了下来但没有任何告警。根因清楚之后修复方案是把“生成出场流水”和“释放堆场位置”放进同一个数据库事务里两步要么同时成功要么一起回滚并且都写 move_log。同时给队列任务的失败加上了告警。这个事故给我的教训是业务上看起来是一个动作代码里如果拆成主流程和异步流程就必须想清楚失败时的补偿方案。5. 安全加固与依赖升级别让双框架变成双风险5.1 认证授权的一体化双框架项目最不安全的做法是每个框架各自维护一套用户表和权限逻辑。用户这边改个密码那边还不知道权限中间件各写各的审计起来也费劲。我做的是一体化统一 users 表、roles 表、permissions 表Laravel 通过中间件做 token 校验ThinkPHP 老后台通过统一 session 判断身份。任何新接口都从同一个地方获取当前用户信息不允许各自造轮子。第三方对接也走独立认证体系不直接拿账号密码。每个对接单位发一个独立的 API token按调用方维度做限流。这个设计在码头这种多单位协作场景里特别重要海关、船公司、车队、堆场各有各的系统接口每天都有人扫。5.2 参数校验、文件上传与接口限流的边界控制我在老系统里翻到过把用户输入直接拼进 SQL 的写法这种代码在双框架项目里是最需要盯的对象。给 ThinkPHP 老代码做了一次全量审计把所有动态拼接 SQL 的地方改成参数绑定Laravel 侧从一开始就只用 ORM 和查询构造器不让原生 SQL 裸奔。文件上传同样只允许白名单扩展名文件落库后重命名成随机字符串存储目录放到 web 根目录之外防止有人直接通过 URL 访问。对外接口加全局速率限制超过单位时间窗口就返回 429。这些不是锦上添花在真实生产环境里每一条都拦住过实际攻击。5.3 框架安全补丁的跟进节奏框架老化不可怕可怕的是上线之后没人管版本。我当时给项目定了三条规矩每周用依赖扫描工具跑一遍 composer audit关注两个框架官方安全公告只要涉及生产版本就打补丁升级前在灰度环境跑一遍闸口、堆场、开票这几条核心链路。前阵子 Laravel 的签名 URL 校验规则漏洞被公开讨论编号 CVE-2024-29291网上各种各样的复现文章满天飞。我没去跑那些脚本因为生产环境最该做的是确认官方修复版本在隔离环境升级依赖然后把核心作业流程回归一遍而不是围观攻击演示。对团队来说把两个框架的依赖版本都维持在官方支持区间比收藏十篇漏洞分析都管用。安全这件事本质上拼的不是谁更懂黑客而是谁先把该打的补丁打上。6. 性能优化与线上稳定性码头作业经不起等待6.1 慢查询优化先让每张核心表都有合适的索引闸口过车高峰一旦卡顿第一个要查的就是数据库。我上线初期把慢查询日志打开很快发现最典型的问题container 表按箱号查货柜居然全表扫描因为没建索引。给 container_no 加上索引之后单条查询从几百毫秒降到个位数毫秒。move_log 这种流水表也容易全表扫。一开始没建索引后来查询条件按 container_no 加 created_at 组合走我就建了对应的联合索引。这里提醒一句不要等线上卡了才想起来优化。运维阶段定期统计慢查询、看 performance_schema 里的 TOP SQL比临时救火强太多。6.2 堆场容量与作业看板Redis缓存热点数据堆场看板每几秒刷新一次如果每次都实时查询泊位占用、堆场剩余容量、闸口排队数数据库根本扛不住。我把这类读多写少的数据放到 Redis 里缓存过期时间设置 5 到 10 秒并且加上随机抖动避免大量 key 在同一时刻集体失效。更新策略是“先更新数据库再删缓存”允许短暂弱一致但保证最终能看到最新数据。缓存万一失效且请求并发特别大我担心缓存击穿所以用一个互斥锁让单个请求去重建缓存其他人短暂等待或读旧值。这个策略对付看板类接口非常有效大屏响应速度从之前的两三秒压到了几百毫秒以内。6.3 报表和大屏接口的异步化改造老报表以前是 PHP 页面同步渲染数据量一增大就超时。我的改造思路很简单把统计逻辑搬进定时任务预先算好结果落到聚合表里接口只查聚合表。比如按小时统计进出闸口量、按船次统计装卸进度、按客户统计提柜量。这里有几个细节要注意。聚合任务要记录统计时间范围方便和原始流水对账任务失败要能自动重跑聚合结果表只做查询不做更新。这个改造做完之后大屏接口基本稳定在一秒以内再也没因为报表超时被现场打电话。6.4 部署与回滚两个应用可以独立发布部署层面我给两个应用做了独立 release 目录每次发布创建一个带时间戳的新目录软链切过去。发布顺序先 Laravel 侧再 ThinkPHP 侧Nginx 全程不用重启php-fpm 平滑 reload 即可。如果新版本出了问题回滚就是把软链指回上一个 release 目录一分钟完成。数据库迁移也遵循同样的思路迁移脚本保持向下兼容先加新字段等代码发布完再逐步清理旧字段不轻易用破坏性 ALTER。线上开了最基本的监控PHP 错误率、接口耗时、队列积压异常就告警。双框架项目最忌讳的是只知道某个模块挂了却连日志都找不到在哪所以日志格式和 request_id 这种基础设施必须在刚开工时就做好。最后说点个人体会。这个项目做下来我对“一个项目到底该用几个框架”这件事有了更实在的看法只要业务在真实运转、需求在不断变化技术栈就一定会留下历史痕迹。双框架不一定是坏味道数据模型清晰、日志能串联、部署能独立发布它也能成为一个顺手的过渡方案。我后来再遇到有人问要不要把 ThinkPHP 全量迁到 Laravel我的建议始终是先拆接口、先统一数据流等边界清楚了再谈重写。真正决定一套系统寿命的不是选了哪个框架而是状态怎么流转、数据怎么对齐、线上能不能快速定位问题。这套理念应该不止适用于码头货柜系统。
返回列表