Skip to content
// 0x
0x0E // 后端实践

在 PHP 5.6 遗留系统中设计 Shopify 模块:架构取舍

背景

这是一个运行了十年以上的 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 的字符串中处理起来非常别扭。最终选择了类名前缀方案来替代命名空间隔离:

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 后缀的包装方法(graphqlRequestSaferestRequestSafe),负责将异常捕获为返回值:

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_ordersshopify_lineitemshopify_shops 等),但同时需要操作 WMS 的 3 张核心业务表(shippinglineiteminventry)。

设计原则:模块只能通过 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 成本远大于收益。端到端测试虽然慢一点,但测的是真实的集成质量。

安全修复

在触达老代码的过程中,发现了三条被长期掩盖的安全漏洞:

这些漏洞和解耦工作没有直接关系,但它们在同一段代码路径上存在了多年而未被发现,说明重构本身就是一次免费的代码审计

总结

这个 Shopify 模块的架构设计不是一个完美的案例。它受限于 PHP 5.6、无框架、未使用命名空间的遗留约束,每一步都是在”最优解”和”可行解”之间妥协。

Laravel 当然可以更优雅地实现这一切——但在 PHP 5.6、无 Composer、全局变量的遗留系统里谈优雅设计,就像在泥巴路上比跑车的流线型。车是好车,路不是那条路。

所有脱离运行环境和语言版本限制的优雅设计都是在耍流氓。这个模块的每个架构决策,最终都回到同一个问题:*在这个约束下,什么方案是成本最低的可维护解?*答案往往不是漂亮的,但它是真实的。

几个可以复用的经验:


Share this post on:

Previous Post
mPDF 6.0 动态图片渲染踩坑:当 base64、PNG、绝对路径全都失效
Next Post
工单系统的重生:从 Demo 到 WMS 集成