架构总览:十四个模块与微服务拆分

上一章我把品达 TMS clone 下来、在根目录打了个 v0-baseline 标签。第一次在 IDE 里展开这个工程时,说实话有点眼晕——根目录下齐刷刷趴着 14 个 pd- 开头的目录,还有一个父工程套着十几个子模块的 pd-service-api。如果不先建立一张架构地图,后面读代码会一直在"这个类到底属于哪个服务"里打转。
这一章我就带你把这 14 个模块的分工、它们之间怎么通信、以及这套架构里最容易绊新人一跤的那个设计,一次讲清楚。读懂这张图,后面九章你都不会迷路。
先分层:十四个模块各干什么
我不按工程目录的字母顺序讲,而是按"请求从上到下流动"的层次来讲。整个仓库是 12 个后端 Maven 模块加 2 个前端工程,我把它们归成四层。
最上面是终端 BFF 层:pd-web。 这个模块有点特别,它一个工程里居然有四个启动类:WebManagerApplication(管理员端)、WebDriverApplication(司机端)、WebCourierApplication(快递员端)、WebCustomerApplication(客户端)。它的定位是面向不同终端的 API 适配层——同一笔业务,管理员要看全量、要带操作权限;司机只看自己的作业单;客户只查自己的订单和轨迹。pd-web 负责把这些差异化的视图组装好,底下再去调各个领域服务。这种"多端一个 BFF 工程、按启动类切分"的做法,省掉了重复建四个工程的成本。
中间是业务域层,四个核心服务,也是整个 TMS 的肉。
pd-oms:订单域。客户下单、货物与收发货地址、Drools 计费、支付与结算单都在这儿。一单货的故事从这里开始。pd-dispatch:调度域,整个系统的大脑。订单分类、路线规划、车次车辆司机匹配、调度任务与异常单,第 5 到 7 章的主角。pd-work:作业域。调度规划完之后,运输任务、运单、司机作业单在这里被执行,发车、到达、交付的状态流转都在这个服务落库。pd-netty:轨迹域。独立的 Netty TCP 服务,专门接车载终端和司机手机回传的 GPS 点位,顺带负责告警与轨迹归档。它单独成服务是有道理的——长连接的负载特征和普通 HTTP 接口完全不同,必须能独立扩缩容。
再往下是主数据与支撑层。 pd-base 管机构、运输线路、车辆、车次这类基础主数据;pd-user 管用户;pd-auth 管登录认证与鉴权,用的是 Sa-Token;pd-aggregation 是聚合服务,给管理首页、监控大屏这类需要跨多个域拼数据的场景提供统一出口。
最底下是共享与基建层。 pd-gateway 是 Spring Cloud Gateway 统一入口;pd-common 放跨服务通用的工具、上下文和基类,比如后面会反复出现的租户上下文 TenantContextHolder;pd-service-api 则是服务之间共享的契约层,它内部按域拆成了成对的 api 与 domain 子模块(oms、work、base、user、auth、aggregation 各一对),Feign 接口、DTO、枚举、对外实体都在这里。
两个前端工程则是 pd-admin-ui-vue3(Vue3 管理后台)和 pd-mobile(司机/快递员移动作业端)。
我把它压成一张表,你可以贴在工位上:
| 层 | 模块 | 一句话职责 |
|---|---|---|
| BFF | pd-web | 管理员/司机/快递员/客户四端 API 适配 |
| 业务域 | pd-oms | 下单、计费、支付、结算 |
| 业务域 | pd-dispatch | 分类、寻路、资源匹配(大脑) |
| 业务域 | pd-work | 运输任务执行与状态流转 |
| 业务域 | pd-netty | GPS 长连接接入、告警、轨迹归档 |
| 支撑 | pd-base | 机构/线路/车辆/车次主数据 |
| 支撑 | pd-user / pd-auth | 用户 / 登录鉴权(Sa-Token) |
| 支撑 | pd-aggregation | 跨域数据聚合(大屏、首页) |
| 基建 | pd-gateway | 统一网关入口 |
| 基建 | pd-common | 工具、上下文、基类 |
| 契约 | pd-service-api | Feign 接口与 DTO/枚举共享层 |
| 前端 | pd-admin-ui-vue3 / pd-mobile | 管理后台 / 移动作业端 |
服务之间怎么说话:Nacos + OpenFeign
微服务拆这么细,协作全靠两件套:Nacos 负责注册发现和配置下发,OpenFeign 负责像调本地方法一样调远程服务。
调度服务是发起远程调用最频繁的一个。在 pd-dispatch 的资源匹配逻辑里,你能看到一排 Feign 客户端:
@Autowired private TransportTripsFeign transportTripsFeign; // 车次
@Autowired private TruckFeign truckFeign; // 车辆
@Autowired private UserFeign userFeign; // 司机
@Autowired private OrgFeign orgFeign; // 机构
规划路线时,调度服务自己不存线路,而是调 pd-base 的 transportLineFeign.list(dto) 去查运输线路;匹配车辆时调 truckFeign.findAll(...) 拿车辆、再按状态过滤;查司机则调 pd-auth 那边的 userFeign.list(...)。也就是说,pd-dispatch 本质上是一个"编排者",它自己不产生主数据,只负责把 pd-base、pd-auth 的数据拿来做决策,再把决策结果写回自己的调度表。
这个定位划分我很认同:主数据唯一归属,决策服务只持有决策过程数据。 车辆叫什么名字、属于哪个机构,只在 pd-base 有一份权威记录,调度服务绝不复制,避免了多服务各存一份导致的数据打架。
pd-netty 里也有类似的调用,但它用的是 TruckBaseClient——轨迹服务收到一个点,要回写车辆的最近心跳时间,就得跨服务去通知 pd-base。
最容易绊脚的设计:跨服务的 ID 居然不是一个类型
接下来是这套架构里我觉得最值得讲、也最坑新人的一个点:同一个"机构 ID""线路 ID",在不同服务里的数据类型居然不一样。
在 pd-dispatch 内部,机构 ID、线路 ID 都是 Long(落库是 BIGINT,配雪花算法)。但一旦跨进程去调 pd-base,ID 全变成了 String。代码里这个转换随处可见,而且原作者很贴心地写了注释提醒。在路线规划的入口:
// findLine 递归内部全程走 pd-base String 边界,
// 仅在入口把 disp 内部 Long 机构转 String
RoutePlanningDTO routePlanningDTO = findLine(
0,
IdConvert.toStr(orgId),
IdConvert.toStr(item.getStartOrgId()),
IdConvert.toStr(item.getEndOrgId()),
new ArrayList<>());
资源匹配落库时又得转回来:
// 车次/车辆ID为 pd-base String 边界,落库 BIGINT 前转换;司机ID内部即 Long
orderClassifyAttachEntity.setTripsId(IdConvert.toLong(tripsTruckDriverDTO.getTripsId()));
orderClassifyAttachEntity.setTruckId(IdConvert.toLong(tripsTruckDriverDTO.getTruckId()));
orderClassifyAttachEntity.setDriverId(tripsTruckDriverDTO.getDriverId());
为什么会这样?这是微服务迭代过程中很典型的"历史边界":pd-base 作为最老的主数据服务,对外契约里把 ID 定义成了字符串(很可能早期是为了兼容某些非数字主键或前端直传);而后来新建的调度、作业域统一用了 Long。两边都已经上线、谁也不敢轻易改契约,于是就靠 IdConvert.toStr() / IdConvert.toLong() 在边界上做转换。
我第一次读到这儿时有点嫌弃,觉得这是技术债。但读完整个调用链,我反而认可了这个妥协的纪律性:类型不一致本身不致命,致命的是边界不清、到处随手 String.valueOf()。 这套代码把所有转换收口到一个 IdConvert 工具类,并且在调用处写清"这里是 pd-base String 边界",把一个丑陋的历史包袱约束得明明白白。我二开时给自己立了同样的规矩——边界转换只准用 IdConvert,不准在业务代码里散落裸转换,否则半年后没人记得哪个 ID 是哪种类型。
代码里还能看到另一层防御:远程调用可能失败,Feign 降级会返回 null。原作者在多处显式处理了这种情况,比如查车次时"仅远程调用失败(fallback 返回 null)时按无数据降级,避免 NPE;空列表保持原有行为"。分布式系统里,把"远程挂了返回 null"和"远程正常但没数据返回空列表"区分开,是个很实战的意识。
一个横切所有服务的概念:多租户
这套系统支持多机构(物流公司)隔离,租户上下文 TenantContextHolder 放在 pd-common 里,所有服务共享。正常的 HTTP 请求,租户 ID 由网关注入、Feign 拦截器透传,业务代码无感知。
但有一类场景没有 HTTP 请求头——定时调度线程和 GPS 消息消费线程,它们是系统自己触发的,天然没有登录态。这个问题在调度改造里被专门处理了:调度任务执行前,用 OrgTenantResolver 按机构 ID 反查出租户、塞进当前线程上下文,执行完在 finally 里清掉,防止线程池复用把 A 公司的租户串到 B 公司。这是多租户微服务非常经典的一个坑,我在第 5 章会结合代码细讲,这里先给你埋个印象:凡是脱离请求线程的异步/定时逻辑,都要自问一句"我的租户上下文从哪来、什么时候清"。
两套前端,对应两种完全不同的作业场景
后端之外,仓库里还有两个前端工程,对应两类截然不同的使用姿势,这个拆分本身也是架构的一部分。
pd-admin-ui-vue3 是管理后台,基于 Vue3,跑在 PC 浏览器里,给调度员、财务、管理员用。它追求的是信息密度和复杂操作:满屏的订单表格、调度监控大屏、电子围栏绘制、结算对账列表,一屏塞得下越多数据越好。它通过网关访问 pd-web 里那个 WebManagerApplication 暴露的管理端接口,鉴权用的是 Sa-Token,登录后带着 Token 访问、后端按角色控制按钮和数据权限。
pd-mobile 则是移动作业端,给司机和快递员在手机上用。它不追求信息密度,而追求单手、强光下、颠簸中也能点得准:几个大按钮——提货拍照、发车确认、到达确认、交付签收,配合作业定位和照片上传。它访问的是 pd-web 里司机、快递员专属的启动类接口,能看到的数据被严格限定在"分配给当前这个人的任务"。
别小看这个划分。很多团队一开始图省事,让司机也用管理后台的手机适配页凑合,结果把调度功能、全量数据暴露给了一线司机,既难用又危险。管理端和作业端在使用者、数据范围、交互形态上完全不同,从 BFF 启动类到前端工程彻底分开,反而是最省事、最安全的选择。
我的二开取舍:守住模块边界
摸清架构后,我给自己定了三条改造原则。
第一,尊重领域边界,绝不让一个服务越界去直接查别人的库。调度要线路数据就走 Feign,哪怕多一次网络调用也不图省事直连 pd-base 的表——微服务一旦开了跨库直查这个口子,拆库、独立部署就全成了空话。
第二,新增能力先问"它属于哪个域"。比如我后来加的围栏告警,天然属于轨迹域,就放进 pd-netty;加的司机结算规则属于订单/结算域,就放进 pd-oms。架构的清爽不是靠一次性设计,而是靠每次新增时都不把东西放错抽屉。
第三,共享的东西才进 pd-service-api 和 pd-common。只有一个服务用的 DTO 绝不往共享层塞,否则共享层会迅速膨胀成一个谁都不敢动的垃圾场。
这一章没有复杂算法,但这张分层地图是后面一切的地基。下一章,我们跟着一单货的旅程,进入订单域——看它下单后要走过的 12 个状态,以及这套系统怎么用 Drools 规则引擎把运费算明白。
