SaaS系统做多租户,业务差异化是最绕不开的问题。同样一个价格计算,A租户走阶梯价,B租户走一口价,C租户要叠加会员折扣。同样一个订单校验,有的租户要校验库存,有的不需要。
这类差异如果用if-else堆,代码很快就没法看了。如果用继承体系,每个租户一个子类,类的数量会随租户数线性增长,而且租户之间的差异往往不是整块替换,而是某个小环节不同,继承粒度对不上。
我们可以用扩展点机制,来专门解决这类问题。我诺干年前在一个实际生产项目里用过,代码量不大,设计还是蛮精巧的,值得拿出来聊聊。
扩展点是什么
扩展点机制的思路只有三步:
- 把差异化的逻辑抽象成接口,叫扩展点(ExtensionPoint)
- 每个租户提供自己的实现,叫扩展(Extension)
- 运行时根据业务身份(bizCode)自动路由到对应的实现
业务代码只依赖扩展点接口,不依赖任何具体实现类。新增一个租户的差异逻辑,加一个@Extension类就够了,不用改已有代码。
声明扩展点
扩展点是一个接口,继承ExtensionPoint标记接口,命名必须以ExtPt结尾:
Java
// 扩展点标记接口
public interface ExtensionPoint {
}
// 价格计算扩展点
public interface PriceCalcExtPt extends ExtensionPoint {
BigDecimal calculate(Order order);
}
ExtPt后缀是框架的硬性校验,不是建议。ExtensionRegister在注册时会扫描类实现的接口,只认以ExtPt结尾的。这个约束的好处是:看到以ExtPt结尾的接口,一眼就知道它是扩展点,不需要翻文档确认。跟Spring里@Repository、@Service这类注解的约定是同一个思路,命名即文档。
实现扩展点
不同租户提供各自的实现,用@Extension注解标记,bizCode标识业务身份:
Java
// 默认实现,不指定bizCode
@Extension
public class DefaultPriceCalcExt implements PriceCalcExtPt {
@Override
public BigDecimal calculate(Order order) {
return order.getUnitPrice().multiply(BigDecimal.valueOf(order.getQuantity()));
}
}
// 租户A的实现
@Extension(bizCode = "ali.taobao.supermarket")
public class TaobaoSupermarketPriceCalcExt implements PriceCalcExtPt {
@Override
public BigDecimal calculate(Order order) {
// 超市走阶梯价
return stepPriceCalculate(order);
}
}
// 租户B的实现
@Extension(bizCode = "ali.taobao")
public class TaobaoPriceCalcExt implements PriceCalcExtPt {
@Override
public BigDecimal calculate(Order order) {
// 淘宝走满减
return discountCalculate(order);
}
}
不写bizCode就是默认实现,兜底用的。
bizCode逐级路由
这是扩展点机制最有设计感的部分。
运行时,ExtensionExecutor根据当前上下文的bizCode找到对应的Extension实现。查找不是简单的精确匹配,而是逐级回退:
Java
protected <Ext> Ext locateExtension(Class<Ext> targetClz,Context context) {
String bizCode = context.getBizCode();
// 精确匹配
extension = firstTry(targetClz, bizCode);
if (extension != null) return extension;
// 逐级去掉末尾段匹配
extension = loopTry(targetClz, bizCode);
if (extension != null) return extension;
// 兜底默认实现
extension = tryDefault(targetClz);
if (extension != null) return extension;
throw new BizException("找不到扩展实现");
}
如果bizCode是ali.taobao.supermarket,路由过程是:
- 精确查找ali.taobao.supermarket有没有对应的Extension
- 没有的话,去掉末尾段,查找ali.taobao
- 还没有,再去掉末尾段,查找ali
- 最后兜底到默认实现(bizCode为defaultBizCodedefaultBizCodedefaultBizCode)
loopTry的实现就是不断找最后一个点号,截断bizCode,逐级向上查找:
Java
private <Ext> Ext loopTry(Class<Ext> targetClz, String bizCode) {
if (bizCode == null) return null;
int lastDotIndex = bizCode.lastIndexOf(".");
while (lastDotIndex != -1) {
bizCode = bizCode.substring(0, lastDotIndex);
extension = extensionRepository
.getExtensionRepo()
.get(new ExtensionCoordinate(targetClz.getName(), bizCode));
if (extension != null) return extension;
lastDotIndex = bizCode.lastIndexOf(".");
}
return null;
}
这个逐级回退的设计很实用。比如你有一个ali级别的通用实现,ali下面所有子业务(ali.taobao、ali.taobao.supermarket、ali.taobao.xxxxxxx)如果没有自己的特化实现,都会自动命中ali的实现。只有需要差异化的地方才单独写一个Extension,不用每个层级都重复写。
这个路由策略跟Java的ClassLoader委派模型是同一个思路:先从最具体的层级找,找不到就向上一级,最后兜底。区别在于ClassLoader是从父到子,扩展点是从子到父,方向反过来了,因为扩展点的语义是"越具体越优先"。
ExtensionRepository:注册与防冲突
所有Extension在应用启动时注册到ExtensionRepository里:
Java
@Component
public class ExtensionRepository {
private Map<ExtensionCoordinate, ExtensionPoint> extensionRepo = new HashMap<>();
}
ExtensionCoordinate是扩展点的坐标,由扩展点接口的全限定名和bizCode组合而成。同一个扩展点接口下,不同bizCode的实现可以共存。同一个bizCode下,不允许有两个实现。
注册时的防重复检查:
Java
ExtensionPoint preVal = extensionRepository
.getExtensionRepo()
.put(extensionCoordinate, extension);
if (preVal != null) {
throw new BizException(
"Duplicate registration: " + extensionCoordinate);
}
如果同一个ExtensionPoint + bizCode有两个实现类,启动时就报错,不会留到运行时才发现冲突。这个设计比Spring的条件化Bean装配更直接:冲突在启动阶段就暴露,不会出现两个Bean注入歧义导致的运行时异常。
ExtensionRepository用的是HashMap不是ConcurrentHashMap。因为所有注册操作都在Bootstrap的init方法里完成,init在Spring容器启动阶段执行,是单线程的。注册完成之后只做读操作,HashMap的读在没有并发写的情况下是线程安全的。不需要ConcurrentHashMap,省掉了不必要的锁开销。
Bootstrap:注解驱动的自动注册
注册过程不需要业务方手动操作。Bootstrap在应用启动时扫描配置的包路径,根据类上的注解自动注册:
Java
public class Bootstrap {
private List<String> packages;
private RegisterFactory registerFactory;
public void init() {
Set<Class<?>> classSet = scanConfiguredPackages();
registerBeans(classSet);
}
private void registerBeans(Set<Class<?>> classSet) {
for (Class<?> targetClz : classSet) {
RegisterI register = registerFactory.getRegister(targetClz);
if (null != register) {
register.doRegistration(targetClz);
}
}
}
}
RegisterFactory根据注解类型选择对应的注册器。遇到@Extension注解的类,交给ExtensionRegister处理。ExtensionRegister从@Extension注解提取bizCode,从类实现的接口中找出以ExtPt结尾的接口作为扩展点,组装成ExtensionCoordinate注册到ExtensionRepository。
业务方只需要在类上加@Extension注解,不需要任何额外配置。
业务代码怎么用
Java
@Service
@RequiredArgsConstructor
public class OrderService {
private final ExtensionExecutor extensionExecutor;
public Response createOrder(CreateOrderCmd cmd) {
// 根据当前上下文的bizCode,自动路由到不同的实现
BigDecimal price = extensionExecutor.execute(
PriceCalcExtPt.class,
cmd.getContext(),
ext -> ext.calculate(order)
);
}
}
调用extensionExecutor.execute,传入三个参数:扩展点接口、上下文(包含bizCode)、要调的方法。ExtensionExecutor根据bizCode找到对应的Extension实例,执行方法,返回结果。
业务代码只依赖PriceCalcExtPt接口,不依赖TaobaoSupermarketPriceCalcExt或DefaultPriceCalcExt这些具体类。新增租户,加一个@Extension类,不改任何已有代码。
小结
扩展点机制解决的是SaaS多租户场景下业务差异的隔离问题,核心就是三个东西:ExtensionPoint接口声明差异点,Extension实现类提供各租户的逻辑,bizCode逐级路由在运行时自动匹配。
bizCode用点号分层、逐级回退的路由策略,让租户差异的逻辑既不需要继承体系的类爆炸,也不需要if-else的圈复杂度失控。父层级的通用实现会被子层级自动继承,只有需要差异化的地方才单独写Extension。这个路由思路跟Java的ClassLoader委派模型异曲同工,只是方向相反:ClassLoader从通用到具体,扩展点从具体到通用。
这种机制的投入产出比是明确的:租户越多、差异化逻辑越复杂,收益越大。系统只有两三个租户且差异很小,上这套机制有些重。但租户规模超过十个,每个租户都有自己的一套业务规则,没有扩展点机制,代码的可维护性会急剧下降。
最近在知乎出了
- 「应付6000万会员的秒杀系统专栏」
- 「几亿用户,百万并发的C端商品系统实战」
- 「技术团队DDD领域驱动设计三年落地实战」
- 「应付亿级用户规模的支付系统代码实战」
专栏,感兴趣的可以订阅一下。至于知识星球的,可以搜:
- 老码头的技术浮生录
它是一个能实际帮你解决难题的星球。有问题的,找知心的Sam哥,支持无限次语音一对一解决你遇到的难题。「另外后续我新写的所有对外的付费专栏,在星球内都是免费的,且可以拿到所有源代码。」
当前星球里免费看的专栏是:
- 「应付6000万会员的秒杀系统专栏」
- 「几亿用户,百万并发的C端商品系统实战」
- 「技术团队DDD领域驱动设计三年落地实战」
- 「应付亿级用户规模的支付系统代码实战」
知识星球内后续将推出20+个付费专栏,覆盖电商全链路:
| 选购线 | 用户会员营销线 | 中后台 |
|---|---|---|
| 购物车服务 | 营销系统 | 订单系统 |
| 商品服务 | 用户系统 | 支付系统 |
| 菜单服务 | 结算服务 |
从前台选购到中后台结算,星球成员全部免费,后续新增也不额外收费。
我的知乎账号:
- SamDeepThinking