SaaS多租户业务差异化:扩展点机制的设计与实现

SaaS系统做多租户,业务差异化是最绕不开的问题。同样一个价格计算,A租户走阶梯价,B租户走一口价,C租户要叠加会员折扣。同样一个订单校验,有的租户要校验库存,有的不需要。

这类差异如果用if-else堆,代码很快就没法看了。如果用继承体系,每个租户一个子类,类的数量会随租户数线性增长,而且租户之间的差异往往不是整块替换,而是某个小环节不同,继承粒度对不上。

我们可以用扩展点机制,来专门解决这类问题。我诺干年前在一个实际生产项目里用过,代码量不大,设计还是蛮精巧的,值得拿出来聊聊。

扩展点是什么

扩展点机制的思路只有三步:

  1. 把差异化的逻辑抽象成接口,叫扩展点(ExtensionPoint)
  2. 每个租户提供自己的实现,叫扩展(Extension)
  3. 运行时根据业务身份(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,路由过程是:

  1. 精确查找ali.taobao.supermarket有没有对应的Extension
  2. 没有的话,去掉末尾段,查找ali.taobao
  3. 还没有,再去掉末尾段,查找ali
  4. 最后兜底到默认实现(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
相关推荐
段一凡-华北理工大学1 小时前
工业领域的Hadoop架构学习~系列文章14:Hadoop集群部署 - 从规划到上线的全流程实践
大数据·数据库·人工智能·hadoop·学习·架构·高炉炼铁
我登哥MVP1 小时前
Spring Boot 从“会用”到“精通”:Rest风格原理
java·spring boot·后端·spring·maven·intellij-idea·mybatis
love_muming1 小时前
数据结构入门:栈与队列详解
java·开发语言·数据结构
@insist1231 小时前
系统架构设计师-信息安全架构综合设计:从数字签名到安全系统
安全·架构·系统架构·软考·系统架构设计师·软件水平考试
我登哥MVP1 小时前
Spring Boot 从“会用”到“精通”:静态资源原理
java·spring boot·后端·spring·tomcat·maven·intellij-idea
奋斗的袍子0071 小时前
springboot集成国密算法SM2
java·spring boot·算法
JAVA面经实录9171 小时前
SpringBoot 全套完整版学习文档(零基础+实战+面试+源码)
java·spring boot·spring·架构
jasnet_u1 小时前
SpringCloud中Feign透传traceId及日志切面配置
java·spring cloud·feign·日志系统
nvd111 小时前
从 Spring 到 Quarkus:为什么依赖注入正在从“运行时”退回“编译期”?
java·后端·spring