背景
这是一个运行了十年以上的 WMS(仓储管理系统)—— PHP 5.6、无框架、全局变量满天飞、同一份 $connection 传递大法。新需求是要在这套系统里嵌入一个 Shopify 集成模块,支持多店铺管理、订单同步、物流回传。
“在泥巴路上跑跑车”——既要复用现有基础设施(数据库连接、会话管理、模板引擎),又要确保新模块有清晰的内部结构,不至于成为下一块技术债。
本文将记录这个模块的架构设计过程和关键决策的取舍逻辑。
约束清单
在开始设计之前,先列清楚不能改的东西:
| 约束 | 影响 |
|---|---|
| PHP 5.6 | 无 ::class 语法,无短数组语法([]),无 ... 展开运算符 |
| 未使用命名空间 | 项目启动于 PSR-4 规范形成之前,类名全局唯一,不能有同名类 |
| 无 Composer | 不能 composer require,不能用第三方 autoloader |
| 无 ORM | 全部手写 SQL + mysqli |
| 无 DI 容器 | 手动 new 一切,依赖关系在代码里硬编码 |
全局 $connection | 所有数据库操作共享同一个 MySQL 连接 |
系统采用 require 路由 | 页面通过 cat/fun 参数决定加载哪个模板文件 |
分层设计
为什么不用扁平脚本
老系统的典型模式是一个 PHP 文件干所有事:
// 老代码模式
$result = mysqli_query($connection, "SELECT ...");
while ($row = mysqli_fetch_assoc($result)) {
// HTML 混在 PHP 里
echo "<tr><td>" . $row['name'] . "</td></tr>";
}
一两个文件这么做没问题,但 Shopify 模块要处理 OAuth 回调、GraphQL 查询、数据库写入、日志记录——如果全塞在一个文件里,600 行只是个开始。
所以选择了六层分离:
shopify/src/
├── Controllers/ # 请求路由 + 响应组装
├── Services/ # 业务逻辑 (API 调用 + 编排)
├── Infrastructure/
│ ├── Logger.php # 基础设施 (日志)
│ └── Repositories/# 数据访问 (SQL)
├── Models/ # 实体定义
├── Support/ # 工具函数 (域名解析、URL生成)
└── ShopifyClient.php # HTTP 客户端封装
没有使用命名空间的折中
PHP 5.6 完全支持命名空间(自 5.3 起引入),但这个项目启动时尚未形成 PSR-4 规范,autoloader 基于类名映射表。如果引入命名空间,要么全面改用 PSR-4 + Composer autoload,要么映射表里带反斜杠——后者在 PHP 5.6 的字符串中处理起来非常别扭。最终选择了类名前缀方案来替代命名空间隔离:
- 类名明确带上模块前缀:
Order(shopify 模型)、OrderService、OrderRepository、OrderController - 在
module.php中维护一个手写的类映射表
spl_autoload_register(function ($class) {
$map = [
'Order' => 'src/Models/Order.php',
'OrderService' => 'src/Services/OrderService.php',
'OrderRepository' => 'src/Infrastructure/Repositories/OrderRepository.php',
'OrderController' => 'src/Controllers/OrderController.php',
// ... 共 22 个类
];
if (isset($map[$class])) {
require SHOPIFY_PATH . '/' . $map[$class];
}
});
这种手动映射的好处是 精确控制加载路径,不依赖文件系统扫描,在 PHP 5.6 上性能最优。缺点是新增类时需要同步更新映射表。我们在 module.php 里按分层加了注释分组来缓解这个问题。
依赖注入的朴素实现
没有 DI 容器,依赖关系通过构造函数手动注入:
class OrderService
{
private $apiClient;
public function __construct($apiClient = null)
{
// 允许替换客户端实现,方便测试和扩展
$this->apiClient = $apiClient ?: new ShopifyClient();
}
}
默认参数允许不传参直接 new OrderService(),但也支持替换依赖——测试时可以注入 mock client。
Repository 层更直接,接收 MySQL 连接:
class OrderRepository
{
private $db;
public function __construct($db)
{
if (!$db instanceof mysqli) {
throw new \RuntimeException('Database connection not available');
}
$this->db = $db;
}
}
构造函数中做类型检查,而不是在调用时才发现连接不可用——这是一种防御性编程的朴素实践。
GraphQL 集成:从 REST 到 GraphQL
为什么迁移
老系统用的是 Shopify REST API + API Key/Password 认证。新模块需要支持 OAuth 2.0 多店铺认证,而 Shopify 对 REST API 的限流是每秒 40 次,GraphQL 是每分钟 1000 点(按查询复杂度折算)。对于订单同步这种批量操作,GraphQL 可以一次查询获取完整订单数据(含地址、商品、履约信息),而 REST 需要多次请求。
客户端封装
HTTP 客户端统一封装在 ShopifyClient 中,提供 REST 和 GraphQL 两种接口:
// GraphQL 请求
$client->graphqlRequest($domain, $token, $query, $variables);
// REST 请求(主要用于 OAuth token 交换)
$client->restRequest($domain, $token, $endpoint, $method, $data);
两种接口都配有 Safe 后缀的包装方法(graphqlRequestSafe、restRequestSafe),负责将异常捕获为返回值:
public function graphqlRequestSafe(...) {
try {
$result = $this->graphqlRequest(...);
return ['success' => true, 'data' => $result['data']];
} catch (Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
这个模式在这类无框架项目中非常实用——调用方不用每个地方都包 try/catch,所有错误统一通过 success 字段判断。
GraphQL 踩坑
迁移过程中遇到了几个值得记录的问题:
最典型的例子是:REST API 的 LineItem 有 grams 字段,但 GraphQL 的 LineItem 类型没有这个字段。而尝试用 variant { weight } 也不行——该字段在 GraphQL 的 ProductVariant 上也不直接可用,需要通过 inventoryItem 嵌套查询。最终我们选择了在订单级别获取 totalWeight,而不是逐行获取单品重量。
另一个问题是 GraphQL 的 orders() 查询默认不分页,必须手动实现游标分页:
query getOrders($first: Int, $after: String) {
orders(first: $first, after: $after) {
pageInfo { hasNextPage endCursor }
edges { node { ... } }
}
}
这和 REST API 的 page-based 分页完全不同。我们在第一次线上测试时就遇到了”只同步了 40 个订单”的问题——因为忘了加分页。
数据边界设计
自有表与外部表的隔离
模块有自己的 7 张表(shopify_orders、shopify_lineitem、shopify_shops 等),但同时需要操作 WMS 的 3 张核心业务表(shipping、lineitem、inventry)。
设计原则:模块只能通过 Repository 层访问外部表,禁止在 Service 或脚本中直接写 SQL 操作 WMS 表。
// 正确的做法
$orderRepo->insertShipping($shippingData);
// 错误的做法(任何脚本中都可能写)
mysqli_query($connection, "INSERT INTO shipping (...)");
这个原则在最初没有被严格遵守——600 行的 sync-orders.php 就是直接在脚本里拼 SQL。直到我们解耦了订单同步和发货单创建,才真正把 WMS 表的操作收拢到了 Repository 中。
两套 UI 的问题
这个模块有一个特殊的架构问题:WMS 系统有两种用户——管理员和客户。他们的出库单列表分别由两个不同的 PHP 文件渲染:
管理员: outbound.php(16 列,含 Source)
客户: customer_outbound.php(14 列,不含 Source)
两套模板共享同一套后端数据查询(各有独立的 *_data.php),但列定义各自维护。这导致了我们在上线后发现客户看不到 Source 列——因为只给管理员版本加了新列,忘了客户版本。
这类问题在遗留系统中很常见。一个简单的预防措施:如果两个模板共享同一个数据实体,它们的列定义也应该从一个公共来源派生,而不是各自独立复制。
OAuth 多店铺认证
Shopify 的 OAuth 流程和其他平台的标准 OAuth 一致,但有一个点值得注意:CSRF state 参数必须校验。
我们的 auth.php 生了 state 存到 session,但 callback/index.php 从未验证它。这意味着攻击者可以构造一个恶意链接,让用户授权攻击者的店铺,从而将攻击者的 access_token 存入用户的系统。
这属于 OAuth 实现中的常见遗漏。相比之下,Shopify 提供的 HMAC 校验反而被正确实现了—我们花了 100% 的精力在 HMAC 上,却忽略了 state 参数。事后复盘,这种”做对了一个安全措施就觉得安全了”的心态值得警惕。
解耦的演进
模块最初的同步流程是一条 600 行的脚本一次性完成四件事:拉订单、存数据、创建发货单、分配库存。这种”一条龙”模式在初期快速上线时很高效,但很快暴露了问题。
我们把脚本拆成了两个独立步骤:
sync-orders (只拉订单)
│
▼
create-shipments (只创建发货单)
这个拆分的直接收益是:订单获取不再依赖 WMS 表的可用性,数据可以先落地再异步处理。同时也让测试变得更简单——每个步骤可以独立验证。
更详细的解耦过程写在系列的另一篇文章中(0x07)。
测试策略
在这个项目中,我们选择了 shell + docker exec 的端到端测试,而不是 phpunit:
shopify/tests/
├── run.sh # 主入口
├── lib.sh # 断言函数库
├── 01-sync-orders.sh # 验证订单同步
├── 02-create-shipments.sh # 验证发货单创建
└── 03-single-order.sh # 验证单订单重同步
每条测试用例都是真实的数据库操作和 API 调用,不是 mock。为了安全,添加了 SHOPIFY_TEST_MODE=1 环境变量作为门禁,防止在生成环境误执行。
为什么不用 phpunit?因为代码的架构耦合度太高(全局变量、静态方法、无接口抽象),mock 成本远大于收益。端到端测试虽然慢一点,但测的是真实的集成质量。
安全修复
在触达老代码的过程中,发现了三条被长期掩盖的安全漏洞:
- SSL 证书验证被
CURLOPT_SSL_VERIFYPEER = false全局关闭 - OAuth CSRF state 未校验
- 开放重定向:return_url 不校验即可跳转任意外部链接
这些漏洞和解耦工作没有直接关系,但它们在同一段代码路径上存在了多年而未被发现,说明重构本身就是一次免费的代码审计。
总结
这个 Shopify 模块的架构设计不是一个完美的案例。它受限于 PHP 5.6、无框架、未使用命名空间的遗留约束,每一步都是在”最优解”和”可行解”之间妥协。
Laravel 当然可以更优雅地实现这一切——但在 PHP 5.6、无 Composer、全局变量的遗留系统里谈优雅设计,就像在泥巴路上比跑车的流线型。车是好车,路不是那条路。
所有脱离运行环境和语言版本限制的优雅设计都是在耍流氓。这个模块的每个架构决策,最终都回到同一个问题:*在这个约束下,什么方案是成本最低的可维护解?*答案往往不是漂亮的,但它是真实的。
几个可以复用的经验:
- 手动 autoloader + 类映射 在无 Composer 的场景下比文件扫描更可靠
- 分层不是目的,控制依赖方向才是 — Repository 层作为数据访问的围墙,比六层架构本身更有价值
- 两套 UI 共享同一数据实体时,列定义应统一管理,否则迟早会不同步
- OAuth 安全措施要逐条对照 RFC 检查,只靠经验容易遗漏
- 遗留系统的每次代码触碰都是一次安全审计机会