ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

若依多租户版modules下创建子模块完整指南

2026/10/6 3:25:42 拓冰建站 浏览量
若依多租户版modules下创建子模块完整指南 接手一个基于若依多租户版的老项目时大概率会遇到同一件事业务方要上一个新模块但东西不能往 system 模块里塞得在ruoyi-modules下单独拉一个子模块。我最初以为这就是普通的 Maven 多模块操作结果连做带排查折腾了两天。真正让人懵的不是建工程而是这个多租户版本在 gateway、认证、Mybatis-Plus 租户插件、Nacos 配置之间有一整套约定任何一环没接上启动可能没问题但一跑业务就是数据串、接口 404、租户过滤失效。这篇文章就围绕若依多租户版 modules 中创建子模块这件事把我踩过的和后来总结干净的路径完整写一遍。适合正在用 RuoYi-Cloud-Plus 类分支做二次开发、准备新增独立业务服务的开发者参考。1. modules在若依多租户版里到底扮演什么角色1.1 从目录结构看清整个微服务家族如果你打开若依多租户版本的工程我这里以 RuoYi-Cloud-Plus 2.x 分支为准RuoYi-Cloud 原版思路类似最上层目录一般长这样ruoyi-ui/ ruoyi-gateway/ ruoyi-auth/ ruoyi-modules/ ruoyi-common/ pom.xml其中ruoyi-modules是业务微服务的集合默认已经带了几个模块常见的有ruoyi-system系统管理相关用户、角色、菜单、部门ruoyi-file文件上传下载ruoyi-job定时任务ruoyi-gen代码生成多租户版本与单体版本最大的区别是单体若依里模块指 Java 包子模块就是com.ruoyi.system.controller下的新 controller而在微服务多租户版本里ruoyi-modules下面每个目录都是一个独立的 Spring Boot 服务独立端口、独立数据库、独立部署单元。所以新建子模块的逻辑完全不一样你在ruoyi-modules里加目录本质等于新增一个微服务。有些人不理解为什么不能直接在 system 模块里加业务包。小项目可以但业务多了以后system 模块会同时承担登录鉴权和业务逻辑任何一个业务接口频繁重启都会带着系统管理接口一起重启不同业务团队改同一个代码库也容易冲突。多租户版本拆子模块的价值就是把系统能力和业务能力在进程层面分开。1.2 多租户版新增子模块涉及哪几条链路子模块不是新建完能启动就行。在一个完整的若依多租户微服务体系里新模块要工作至少要串起来下面几条链路服务注册链路子模块启动后要注册到 Nacos网关才能通过lb://服务名找到它。网关路由链路前端请求/xxx/**时网关需要把它转发到新模块这需要新增路由配置。认证鉴权链路网关先校验 token把用户信息、租户信息传递给下游模块新模块要能接收和解析这些信息。租户数据隔离链路子模块操作数据库时Mybatis-Plus 租户插件要自动给 SQL 追加tenant_id条件否则多租户就名存实亡。配置管理链路数据源、日志、Swagger 等配置最好由 Nacos 统一管理而不是散落在本地 application.yml 里。不理解这五条链就动手建模块后面出的问题会非常诡异有时接口打得通但数据混乱有时网关直接报 503有时启动类扫不到 mapper你根本不知道去哪一堆配置里排查。1.3 我说服自己必须新建子模块而非堆包的几条标准在新需求面前判断要不要真的拆一个模块我一般是这么权衡的这个业务的数据是否和系统权限数据有清晰边界这个业务的部署频率是否明显高于系统模块团队是否有独立交付这套业务的口径数据层面是否强烈需要独立的库和独立的租户策略如果答案是多项 yes那就拆。不过拆的前提是这个子模块依然要遵守若依多租户版的框架约定不能在模块外面自建王国。2. 创建子模块前先把版本、数据库和租户边界想清楚2.1 确认你拿到的分支和依赖版本这是很多新手栽跟头的地方。若依本身分支很多多租户版也有不同底座版本分支技术底座模块形态RuoYi-CloudSpring Cloud Alibaba微服务多模块RuoYi-Cloud-PlusSpring Cloud Alibaba Sa-Token Mybatis-Plus微服务多模块支持多租户RuoYi-Vue-PlusSpring Boot 单体 Sa-Token Mybatis-Plus单应用包内分模块也带多租户我用的是 RuoYi-Cloud-Plus。如果你的项目是 RuoYi-Vue-Plus那不存在modules微服务目录创建子模块的方式更接近新建 Maven 子工程然后被主应用扫描目录结构完全不同。所以动手第一步去根 pom.xml 看spring-boot、spring-cloud、spring-cloud-alibaba的版本再决定后面配置写法。我自己就见过一次同事拿单体版本的配置套到微服务版结果 Nacos 根本启动不了。2.2 数据库规划子模块要不要独立库若依多租户版的默认习惯是框架相关表在ruoyi库里业务表可以在同一个库里也可以完全独立。我建议新业务模块直接建独立数据库比如ruoyi_market这样租户插件过滤、数据备份、资源隔离都干净。建库时有一个关键点业务表必须带tenant_id字段并且要建普通索引。多租户过滤最终是WHERE tenant_id ?没有索引时数据量上来会很痛苦。租户插件也不会自动建字段那是 DDL 的事。另外像sys_user、sys_tenant这类全局表租户插件要 ignore。新模块如果自己维护一套业务字典表而这套字典是全局共享不分租户的要么表中不要放tenant_id要么在租户插件里配置忽略。很多人创建子模块后之所以查全表都能看到本质就是表结构和 ignore 配置没有设计好。2.3 租户ID从哪来先理解上下文这个抽象在若依的多租户实现里租户 ID 不是前端每次传一个参数而是经过一个链路用户在登录时携带账号密码认证中心校验后下发 token后续请求都带 token网关解析 token 后把租户信息放在请求头里下游微服务再从请求头取出并写入当前线程的租户上下文。所以子模块里写代码时不要在 service 层到处传tenantId。更多情况是框架已经帮你从上下文里取了。你需要清楚你们项目里这个上下文工具类叫什么。RuoYi-Cloud-Plus 常见是TenantHelper或LoginHelper建议直接在 IDE 里搜getTenantId看它读的请求头名字和存储对象后面对接租户插件和 Feign 时都用同一个数据源。3. 在modules目录中落地一个子模块的完整过程3.1 Maven模块注册与parent坐标假设新模块叫ruoyi-market。先在根工程ruoyi-modules/pom.xml的modules节点中加上这个模块modules moduleruoyi-system/module moduleruoyi-file/module moduleruoyi-job/module moduleruoyi-gen/module moduleruoyi-market/module /modules然后在ruoyi-modules下新建ruoyi-market目录内部创建pom.xml。这里最容易出问题的是 parent 和 version 配不对导致依赖版本冲突。比较稳妥的写法是project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd parent groupIdcom.ruoyi/groupId artifactIdruoyi-modules/artifactId version2.3.0/version /parent modelVersion4.0.0/modelVersion artifactIdruoyi-market/artifactId packagingjar/packaging dependencies dependency groupIdcom.ruoyi/groupId artifactIdruoyi-common-security/artifactId /dependency dependency groupIdcom.ruoyi/groupId artifactIdruoyi-common-log/artifactId /dependency dependency groupIdcom.ruoyi/groupId artifactIdruoyi-common-datasource/artifactId /dependency dependency groupIdcom.ruoyi/groupId artifactIdruoyi-common-mybatis/artifactId /dependency !-- 根据实际需要自行补充 -- /dependencies /project注意版本号不要自己另写直接继承父工程。若依的 starter 依赖已经封装了mybatis-plus、nacos-discovery等组件在子模块里逐个写版本号很容易版本漂移。3.2 启动类与包路径的约定接下来创建启动类RuoYiMarketApplication.java。若依的框架里有很多组件扫描是基于包名com.ruoyi的比如通用异常处理、Swagger 自动化配置、租户上下文过滤器默认会扫描启动类同包及其子包。所以新模块的包路径建议定成com.ruoyi.market然后启动类放在该包的根上package com.ruoyi.market; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cloud.client.discovery.EnableDiscoveryClient; EnableDiscoveryClient SpringBootApplication MapperScan(com.ruoyi.market.**.mapper) public class RuoYiMarketApplication { public static void main(String[] args) { SpringApplication.run(RuoYiMarketApplication.class, args); } }MapperScan的包路径要能覆盖你的 mapper。如果你不喜欢用MapperScan也可以在每个 Mapper 接口上加Mapper但我不想让代码到处是注解统一扫一次更干净。一个常见的坑是MapperScan(com.ruoyi.market.mapper)只扫了一层结果 mapper 放在com.ruoyi.market.order.mapper就永远注入不进去启动报找不到 bean。用**通配符可以避免这类问题。3.3 配置文件从本地到Nacos的顺序子模块的配置可以分为两部分bootstrap.yml只放 Nacos 地址、命名空间、应用名保证启动时能拉取远程配置。application.yml数据源、端口、日志等业务配置。最小可运行的bootstrap.yml示例spring: application: name: ruoyi-market cloud: nacos: server-addr: 127.0.0.1:8848 username: nacos password: nacos discovery: namespace: public config: namespace: public file-extension: yml shared-configs: ->server: port: 9210 spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driverClassName: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ruoyi_market?useUnicodetruecharacterEncodingutf8zeroDateTimeBehaviorconvertToNulluseSSLfalseserverTimezoneAsia/Shanghai username: root password: yourpassword mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true global-config: banner: false在 RuoYi-Cloud-Plus 里很多配置已经通过 Nacos 的共享配置文件管理了子模块的配置必须确认是否被共享配置覆盖。我见过端口在本地写 9210结果 Nacos 里application-common.yml动态设置了server.port8080一启动端口对不上。建议新模块的独有配置单独建一个ruoyi-market.yml放在 Nacos 里而不是全部依赖共享配置。3.4 让前端请求进得来网关路由与白名单模块起来了Nacos 服务列表里也能看到实例了但前端调用还是 404。这时候第一反应应该检查网关有没有加路由。如果你用的是 Nacos 配置网关路由新增一条 JSON 或 YAML。我的ruoyi-gateway路由配置里加的是类似下面的片段{ id: ruoyi-market, predicates: [ { name: Path, args: { pattern: /market/** } } ], filters: [ { name: StripPrefix, args: { parts: 2 } } ], uri: lb://ruoyi-market, order: 404 }这里的StripPrefix、parts和实际网关前缀要看项目约定。若依默认前端会通过/prod-api/market/xxx访问网关去掉前缀后转发到ruoyi-market服务的/xxx。如果你前面的模块接口预览是http://localhost:8080/market/order/list那parts可能需要设置为 1这取决于你服务内部 controller 是否带market前缀。另一个容易漏掉的点是匿名访问。新模块若不想在独立认证中心单独登录比如回调接口、健康检查要学会在网关或安全配置里放行。若依一般有白名单常量比如SecurityProperties、ignore集合新增/market/auth/**这类路径。不要把整个/market/**全放行否则 token 校验就形同虚设。4. 让子模块真正成为租户隔离的一员核心机制对接4.1 租户上下文解析从请求头到线程变量新建后的子模块如果没有特殊处理只有一个SpringBootApplication它是不会自动解析网关传过来的租户 ID 的。你需要确认项目里是否有一个通用过滤器或拦截器在做这件事。典型流程是请求进入子模块。拦截器从请求头读取租户 ID例如 header 名可能是tenant-id或从 token 解析出的用户对象中取tenantId。把租户 ID 存入ThreadLocal类型的上下文对象。请求结束过滤器 finally 中清理ThreadLocal。我建议在子模块里加一个拦截器时不要自己另搞一套TenantContext而是复用框架现有的TenantHelper。因为后续 Mybatis-Plus 租户插件、Feign 传递都要读同一个对象你另建一个类很容易出现过滤器写入 AMybatis 插件读 B的问题。如果没有现成过滤器可以参考下面这段思路实现一个Component public class TenantInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenantId request.getHeader(tenant-id); if (StringUtils.hasText(tenantId)) { TenantHelper.setTenantId(Long.valueOf(tenantId)); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { TenantHelper.clear(); } }这段代码不能直接抄因为不同分支的TenantHelperAPI 差异很大。核心逻辑是通用的从 header 或登录对象解析租户、写入线程上下文、请求结束后清理。4.2 Mybatis-Plus 租户插件为什么你的SQL没有自动带tenant_id多租户数据隔离的底层是 Mybatis-Plus 的TenantLineInnerInterceptor。它会在执行 SQL 之前改写 SQL自动在表后面拼接tenant_id ?。但有个前提你的MybatisPlusInterceptor配置里确实加了这条插件。若依多租户版通常已经全局配置好了可一旦子模块把自己的配置类带进来或者依赖顺序不对就会覆盖掉全局配置。常见现象同样一张表在 system 模块里查询正常带tenant_id到了 market 模块就全表查询数据互相串。如果你在子模块里自定义了MybatisPlusInterceptor务必把租户插件也加回来Configuration public class MarketMybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { Override public Expression getTenantId() { Long tenantId TenantHelper.getTenantId(); if (tenantId null) { throw new RuntimeException(非法租户); } return new LongValue(tenantId); } Override public boolean ignoreTable(String tableName) { // 需要过滤掉全局表比如 sys_*、一些字典表 return tableName.startsWith(sys_); } })); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这里有个细节如果项目里已经有公共的 Mybatis-Plus 配置装配类你自己再写一个可能直接导致插件重复。重复之后 SQL 会被拼两次tenant_id ? AND tenant_id ?虽然不会报错但会多一次条件判断性能打折。在动手前先在依赖里找一下是否已经有TenantLineInnerInterceptor的 Bean有就直接用。4.3 写入数据时别忘了自动填充tenant_id租户插件主要管select/update/delete但insert的时候不会自动帮你往tenant_id字段塞值。如果你建表时tenant_id又没有默认值插入的数据就会是 null 或 0下次查询根本查不到。若依一般提供了MetaObjectHandler自动填充机制比如公共字段createBy、createTime都能自动填。新模块如果里没有匹配到公共的 handler就需要补充tenantId的插入填充Component public class MarketMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { Object tenantId getFieldValByName(tenantId, metaObject); if (tenantId null) { this.strictInsertFill(metaObject, tenantId, Long.class, TenantHelper.getTenantId()); } } Override public void updateFill(MetaObject metaObject) { // 根据业务需要填充更新时间等字段 } }写这个 handler 时要小心如果实体某些场景不想自动填租户 ID需要在代码里显式设为null不能靠它兜底反过来如果某些表本身就是全局表就把它加到租户插件 ignore 列表里否则插入时这个 fill 逻辑会很突兀。4.4 模块之间调用Feign请求怎么把租户带过去如果你的新模块需要调用 system 模块或者其他子模块的 Feign 接口租户 ID 必须继续传递。服务之间不会自动共享原来的请求头需要统一配置一个 Feign 的RequestInterceptor。在子模块或者公共 common 模块里加Configuration public class FeignTenantInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { template.header(tenant-id, String.valueOf(TenantHelper.getTenantId())); } }注意如果 Feign 调用发生在异步线程里TenantHelper.getTenantId()可能已经是空的。这种情况下要用TransmittableThreadLocal或者手动把租户 ID 作为方法参数传入。若依一些版本已经封装了这类能力你只需要确认自己项目里用的上下文是不是可跨线程传递的。还有一个容易被忽略的场景MQ 异步消费。消费者从消息里拿到的是一个业务对象里面未必有tenantId。如果消息里没有消费者这条链路就会变成无租户状态写库时租户字段就是 null。我的做法是生产消息时强制把tenantId放进消息体消费者先解析并设置上下文再处理业务逻辑。5. 上线前实测最容易翻车的几个细节与排查5.1 启动失败Invalid bound statement 和 Mapper 扫描问题子模块刚启动时报错最常见的是Invalid bound statement (not found)。原因是 Mapper 接口找到了但 XML 没找到或者 Mapper 接口没被扫描到。排查顺序我一般这么走先看target/classes里有没有 mapper XML。没有的话检查 pom.xml 是否缺少资源配置build resources resource directorysrc/main/resources/directory filteringtrue/filtering /resource /resources /build确认mybatis-plus.mapper-locations有没有配classpath*:注意带不带星号区别很大。classpath*:mapper/**/*.xml会扫描所有依赖 jar 包里的 mapperclasspath:mapper/**/*.xml只扫当前模块后者可能漏掉公共模块里的 XML。确认 Mapper 接口是否在启动类MapperScan覆盖的包下。这个坑不会在写代码立刻暴露通常是调用具体接口时才炸。所以新模块联调前先跑一个最简单的列表查询把 Mapper 链路验证完再继续。5.2 接口通了但数据串租户过滤为什么在联表时失效如果接口能通却发现 A 租户能查到 B 租户的数据首选检查执行 SQL。打开日志里 mybatis 的 SQL 输出正常应该是SELECT * FROM market_order WHERE tenant_id 1如果看到没有tenant_id原因大概率是表被ignoreTable忽略了。查询 SQL 走了自定义 XML 且是from子查询、UNION 等复杂结构TenantLineInnerInterceptor没有正确改写。实体查询时手动静默了租户条件或者 XML 里自己拼了tenant_id #{tenantId}导致插件误判当前表已经包含租户条件部分场景下会出现重复条件。关联查询时还有一个细节如果给表起了别名插件可能匹配不上别名需要看你们使用的 Mybatis-Plus 版本。老版本对JOIN里带别名的支持不太好这时候用自定义 XML 写 SQL手动where tenant_id ?反而更可控但要在 XML 中从上下文取值。5.3 Swagger/Knife4j 文档不聚合或打不开在若依微服务版本里网关一般集成了聚合文档子模块需要把自己的springdoc信息暴露出来。我遇到的情况是子模块通过网关访问/market/doc.html打不开但直接端口访问 9210 的doc.html是正常的。这通常是网关没有把 docs 路径加上或者子模块的配置里没有允许被聚合。基础配置通常是springdoc: api-docs: enabled: true packages-to-scan: com.ruoyi.market.controller knife4j: enable: true如果聚合仍然看不到去网关的 Swagger 资源服务里手动加上新模块的服务名。这种问题不影响功能但影响联调效率建议在模块创建时一步配到位。5.4 本地多模块启动时端口和配置互相污染本地开发时只启动ruoyi-gateway、ruoyi-auth、ruoyi-system和ruoyi-market是常见组合。这时候要注意 Nacos 的隔离开发、测试、生产环境最好用不同 namespace。否则你本地起的ruoyi-market可能会拉到自己不想用的数据库配置甚至注册到公用的 Nacos干扰别人调试。我习惯给每个子模块约定一个固定端口段比如ruoyi-system: 9200ruoyi-market: 9210ruoyi-order: 9220端口写在 Nacos 对应模块配置文件里本地启动时通过-Dspring.cloud.nacos.discovery.ip127.0.0.1或 IDE 启动参数覆盖。这样虽然增加了一点配置成本但后期几个人同时开发不容易互相打架。6. 后续扩展与维护的一些个人习惯6.1 新子模块的最小接入清单现在让我重新做一个 modules 下的子模块我会按这份清单逐个打钩根 pom 和 ruoyi-modules/pom.xml 已注册模块子模块 pom 继承 ruoyi-modules依赖 common-security/common-datasource/common-mybatis 等启动类包名 com.ruoyi.xxx有 EnableDiscoveryClientbootstrap.yml 指向正确的 Nacos namespace独立数据库已建业务表都含 tenant_id 且有索引Mybatis-Plus 租户插件已生效全局共享表已配置 ignore插入数据时 tenant_id 能自动填充网关路由已加路径前缀与 StripPrefix 匹配若需要匿名访问白名单已最小化Feign 请求头传递租户IDSwagger 聚合可用第一个 Page 查询验证租户过滤正常这份清单看起来很基础但每次都能拦住问题。有几次我以为配置都齐了最后发现租户 filter 是后来新加的另一个模块导致的覆盖问题所以验证环节特别重要。6.2 什么情况下我建议你就别新建子模块虽然这篇文章讲了怎么建但并不是所有需求都适合建。我见过一些项目把模块拆得非常碎一个只有两张表的字典功能也单独拆一个服务结果团队光维护服务间调用就累得不行。多租户微服务版本里每个子模块都意味着独立的发布管道、独立的配置管理、独立的监控指标。如果你的业务非常简单、只有一两个实体和 system 模块没有明显团队边界我更建议先放在 system 模块里等边界清晰后再拆出来。创建子模块的成本不止建目录那一步而是后续每改一次配置都要多处理一个服务。6.3 让后期维护更轻松的几个小习惯日志里尽量带上租户 ID。我通常会在服务内部拦截器或公共字段里输出当前租户排查线上某个租户数据异常时没有租户 ID 的日志基本靠猜。即使无法全局加至少在 Feign 调用的入口处打一条。模块命名保持一致性。若依的模块一般是ruoyi-开头新模块不要随手起个market-service之类的名字不然 Nacos 列表里一半叫ruoyi-一半叫xxx-service不熟悉的人很难定位。表面上是命名问题实际是工程秩序。数据库变更脚本要纳入版本管理。我新建表、加字段都习惯在项目里维护sql/init.sql或类似结构避免上线时手动执行漏掉索引。多租户表的核心是 tenant_id表设计时直接把它放在主键附近作为普通索引或联合索引的一部分后续租户维度查询能省很多事。回到开头说的那次经历。我后来复盘发现真正让人挫败的不是代码难写而是微服务 多租户两套概念叠加后大量隐式约定没有被文档讲透。modules 里新建子模块本质上是在一个成熟的框架里增加一个新的运行进程只有把网关、认证、上下文、租户插件这几根线全部接上这个进程才算真正属于这个家。希望这篇记录能让你少走我走过的弯路。