
很多人刚拿到若依多租户版这套代码时第一反应都是懵的。明明是同一个框架的源码怎么结构和单体的那版差了这么多尤其是打开ruoyi-modules这个目录里面躺着system、job、generator这些模块再看文档里写的“在 modules 中创建子模块”到底是在哪里建、怎么建、建完之后怎么让项目跑起来文档里往往一笔带过。这篇文章就专门把这件“小事”彻底说透。我自己从单机版切到多租户版再到把新业务模块塞进 modules 目录里正常启动、完成租户数据隔离中间踩了不少坑今天全部整理出来。1. 先搞清楚 modules 在若依多租户版里到底是什么角色1.1 多租户版和单体版在结构上的根本差异如果你之前用过若依的单体版本应该很熟悉那套结构一个ruoyi-admin负责启动和接口配置ruoyi-system管用户角色权限ruoyi-framework放框架核心逻辑所有业务代码挤在同一个工程里。部署时打一个 jar 包全部搞定。但多租户版尤其是基于 RuoYi-Cloud 的版本把整个系统拆成了多个微服务。最核心的变化是每个业务功能块都是一个独立的 Spring Boot 应用都有自己的启动类、配置文件、数据库连接、甚至可以独立部署扩容。这些业务功能块统一放在ruoyi-modules这个父目录下。你可以把它当作一个“业务模块收纳盒”里面每一个子目录都是一个可以独立运行的服务。很多人会混淆一个概念在单体版里我们把新业务写成controller、service、mapper放在同一个工程的包路径下这叫“新增一个业务包”而在多租户版里我们需要创建的是一个“微服务模块”它要有自己的pom.xml自己的主启动类自己的 Mapper 扫描路径拆得干干净净。这两个逻辑完全不同如果不搞懂这一点后面写代码很痛苦。1.2 modules 目录里每个子模块的基本构成打开ruoyi-modules目录通常你会看到类似这样的结构ruoyi-modules ├── ruoyi-modules-system ├── ruoyi-modules-job ├── ruoyi-modules-generator └── ruoyi-modules-file以ruoyi-modules-system为例它内部是标准的 Maven 结构ruoyi-modules-system ├── pom.xml └── src └── main ├── java │ └── com.ruoyi.system │ ├── controller │ ├── service │ ├── mapper │ ├── domain │ ├── RuoYiSystemApplication.java │ └── ... └── resources ├── mapper │ └── system └── application.yml在这个模块里com.ruoyi.system 就是业务代码所在的基础包。新建子模块时我们要模仿的就是这套父子结构。在模块层面pom.xml就是它的身份证没有 Maven 配置Spring Boot 根本扫不到你这个新模块。1.3 为什么要坚持在 modules 目录里新建子模块有些朋友觉得麻烦直接在某个现有模块里面塞了个新业务的 controller结果两个模块代码越混越乱租户数据隔离也经常出毛病。在多租户架构下模块边界不仅是代码组织问题更是权限、数据、部署的边界。比如你的系统里要新增一套“设备管理”如果放在 system 模块里面设备相关的接口就要跟着 system 服务一起发布、一起升级、一起承受其他功能带来的负载压力。而如果你独立建一个ruoyi-modules-device那么设备服务可以单独发布、单独配置数据库、单独做限流甚至可以让另一个团队独立维护。更重要的是若依多租户版的租户数据过滤机制是结合网关和模块内拦截器一起做的子模块如果不健康启动租户的上下文就不会被正确初始化后续所有 SQL 都可能出现跨租户的数据泄露。这不是危言耸听我后面会讲一个真实的串租户案例。2. 创建子模块的完整实操流程2.1 Maven 坐标与父级依赖配置你有没有漏这一步第一步不是在 IDE 里新建文件夹而是先想好 Maven 坐标。假设我们要新建一个设备管理模块规划如下artifactId: ruoyi-modules-device groupId: com.ruoyi version: 跟父工程保持一致接下来在ruoyi-modules/pom.xml这个父 POM 的modules标签中加上一行modules moduleruoyi-modules-system/module moduleruoyi-modules-job/module moduleruoyi-modules-generator/module moduleruoyi-modules-file/module moduleruoyi-modules-device/module /modules这个步骤很容易漏。很多人直接复制了子模块的目录结构但父 POM 里没有声明结果 Maven 编译时完全找不到新模块报错信息也很模糊。加了父模块声明之后还要在子模块的pom.xml中引入通用依赖。我的做法是直接参照ruoyi-modules-job的 pom 结构保留最核心的ruoyi-common-security、ruoyi-common-log、ruoyi-common-datasource和ruoyi-common-mybatis。特别要强调那个ruoyi-common-mybatis多租户的租户插件就藏在这里面不引入的话子模块一百个 Mapper 都白写因为根本不会执行租户过滤。2.2 主启动类的关键注解一个都不能少创建RuoYiDeviceApplication.java时要模仿官方子模块的启动类写法SpringBootApplication EnableRyFeignClients public class RuoYiDeviceApplication { public static void main(String[] args) { SpringApplication.run(RuoYiDeviceApplication.class, args); System.out.println(设备管理模块启动成功); } }完整的启动类还会配上MapperScan(com.ruoyi.device.mapper)这个注解可以直接写在启动类上也可以单独用Mapper注解标记每一个 Mapper 接口。建议在启动类上统一配置省得以后 Mapper 多了容易漏标。注意EnableRyFeignClients是若依自定义的 Feign 注解用来扫描各个模块里定义的 Feign 客户端接口。如果你改用了 Spring Cloud 原生的EnableFeignClients要注意 basePackages 扫描范围否则模块之间互相调用接口时会出现找不到对应服务的问题。2.3 配置文件的边界分清楚 bootstrap.yml 和 application.yml在多租户版的微服务架构里配置文件是分层管理的。application.yml通常放本地配置比如端口号、数据库连接、MyBatis 配置等bootstrap.yml则是从 Nacos 配置中心拉取远程配置的入口。我的建议是新模块的application.yml先写清楚这些核心项server: port: 9206 spring: application: name: ruoyi-device datasource: druid: master: url: jdbc:mysql://localhost:3306/ry_cloud?useUnicodetruecharacterEncodingutf8 username: root password: password # 多数据源配置 slave: enabled: true url: jdbc:mysql://localhost:3306/ry_cloud_slave?... username: root password: password这里有个容易踩的坑多租户版虽然叫多租户但数据库往往还是共用的只是通过租户 ID 做逻辑隔离。所以连接数据库时不会为每个租户单独建库数据源配置和普通微服务模块是一样的。租户的区分不在数据源层面而在 MyBatis 插件层面后面我会展开讲。2.4 初始化 SQL 脚本中的租户字段建表时就要埋好伏笔设备模块的表结构我直接建议在初始化 SQL 里加上tenant_id字段create table device_info ( id bigint primary key auto_increment, tenant_id varchar(20) default 000000, device_name varchar(100), device_code varchar(50), status char(1) default 0, create_time datetime, remark varchar(500) );这里要解释一下tenant_id的默认值000000这是若依多租户版里超级管理员的租户编号。当没有上下文租户信息时MyBatis 插件会把这个值当作默认租户。如果你建表时忘了加这个字段后面所有查询语句都会变成where tenant_id ?然后直接报“tenant_id 列不存在”非常头疼。所以建表前先想清楚这张表到底需要不需要做租户隔离。像系统字典表、租户套餐表这类基础表本身是全局共享的不需要加租户字段。3. 子模块如何接入多租户数据隔离机制3.1 租户插件的工作流程为什么叫“透明隔离”若依多租户版的租户隔离核心在ruoyi-common-mybatis里它的原理是拦截所有通过 MyBatis 执行的 SQL 语句在 SQL 的 FROM 表名后面自动拼接一个tenant_id ?的过滤条件。这个?的值哪里来是从当前请求上下文中拿到的。网关在认证用户身份时会把用户的租户信息放入请求头子模块收到请求后从请求头解析出租户 ID然后放入本地线程变量ThreadLocal。这样整个业务链路上执行的每一条 SQL 都不需要手写租户条件。打个比方你住在一个大型公寓楼里租户插件就是物业统一安装的门禁系统。你刷卡进入认证门禁系统根据你的卡判断你是几号楼的住户租户 ID然后你走到哪一扇门系统都会自动帮你判断“这个人能不能进去”不需要每扇门单独安排一个保安去盘问你。这个机制就是透明隔离。3.2 忽略租户过滤的场景与 InterceptorIgnore 注解但是有些表是不需要租户过滤的。比如你查设备类型字典表字典往往是系统全局统一的不存在租户差异如果插件强行加tenant_id条件反而查不到数据。这时就要用InterceptorIgnore注解写在 Mapper 接口方法上InterceptorIgnore(tenantLine true) ListDeviceType selectDeviceTypeList();这个注解要放在方法的上一层表示这个方法执行时忽略租户插件。其实在 mybatis-plus 里也有类似注解但它叫做InterceptorIgnore或者TenantLineIgnore若依用的是自定义实现。如果你在若依多租户版里发现某个查询没有自动加租户条件优先排查是否在接口上写了类似的忽略注解。反过来如果你发现自己写的查询在测试时没有租户隔离效果也要看看是不是ruoyi-common-mybatis没有引入或者租户插件在配置文件里被关闭了。3.3 Mapper XML 文件应该放在哪里扫描不到有多难受子模块创建之后所有 MyBatis 的 XML 文件建议放在src/main/resources/mapper/device/目录下。此时在application.yml中要加一段配置mybatis: mapperLocations: classpath:mapper/**/*.xml typeAliasesPackage: com.ruoyi.device.domain有的版本直接把这个配置写死在公共模块里新模块就不需要重复配置。但如果你发现启动时 Mapper 接口绑定 XML 失败报Invalid bound statement (not found)第一个排查目标就是mapperLocations路径是否覆盖了你的 XML 所在目录。实际上最常见的原因是目录层级不匹配。例如 XML 放在resources/mapper/device/DeviceMapper.xml但配置里写的是classpath:mapper/system/*.xml那么新模块自然绑定不上。另外一个小细节XML 文件的 namespace 必须和 Mapper 接口的全限定名一致。比如com.ruoyi.device.mapper.DeviceMappernamespace 就写成com.ruoyi.device.mapper.DeviceMapper。这个写错的话Spring 容器启动时不会马上报错等调用到具体方法时才会报绑定异常排查起来特别费时间。4. 子模块的增删改查套路直接照搬 system 模块4.1 Controller 层的规范写法RuoYi 封装如何复用若依框架已经封装了一套 BaseController里面提供了AjaxResult、TableDataInfo、日志注解等通用能力。新建的模块 Controller 直接继承它RestController RequestMapping(/device) public class DeviceController extends BaseController { Autowired private IDeviceService deviceService; /** * 查询设备列表 */ GetMapping(/list) public TableDataInfo list(Device device) { startPage(); ListDevice list deviceService.selectDeviceList(device); return getDataTable(list); } /** * 新增设备 */ PostMapping(/add) public AjaxResult add(Validated RequestBody Device device) { device.setCreateBy(getUsername()); return toAjax(deviceService.insertDevice(device)); } }这套写法已经非常成熟。startPage()方法会从请求参数中解析页码和每页条数并利用 ThreadLocal 传入分页插件getDataTable方法则把List包装成符合若依前端表格组件格式的分页对象。你不需要重新造轮子照着 system 模块里面的写法抄即可。但要注意Controller 中的接口路径要加在类级别的前缀里避免与其他模块冲突。比如我上面的例子写的是/device那么前端访问时就是/device/list网关路由里也要配好对应转发规则。4.2 Service 层的接口设计为什么每个业务都要有 IxxxService很多从单体若依转过来的朋友到了微服务模块里会下意识地省略 Service 接口直接写实现类。这种做法短期看着简洁但到了后期模块间通过 Feign 互相调用时就有了麻烦。因为 Feign 客户端接口需要暴露业务方法它依赖的往往是 Service 接口里定义的抽象方法。而且若依自带代码生成器生成的代码也是接口加实现类的双层结构。沿用这个结构能够让你后续接入代码生成器时省很多事。Service 实现类的通用写法Service public class DeviceServiceImpl implements IDeviceService { Autowired private DeviceMapper deviceMapper; Override public ListDevice selectDeviceList(Device device) { return deviceMapper.selectDeviceList(device); } }这里想说个小技巧如果你的业务逻辑只需要做简单的增删改查直接在实现类里调 Mapper 就行不要刻意加事务注解。只有当一笔操作涉及多张表写入时才加Transactional否则事务的开启会白白增加数据库连接开销。如果你在微服务场景下错误地使用了本地事务去操作多个模块的数据库根本达不到分布式事务的效果还会造成连接持有时间过长。4.3 Mapper 接口与 XML 的对应关系一个不小心就报错Mapper 接口例子public interface DeviceMapper { ListDevice selectDeviceList(Device device); Device selectDeviceById(Long deviceId); int insertDevice(Device device); int updateDevice(Device device); int deleteDeviceByIds(Long[] deviceIds); }XML 文件里selectDeviceList的 SQL 写法需要特别注意动态条件的拼接。若依的习惯是使用where标签自动去掉多余的 AND 或者 OR。这样写有一个好处当所有查询条件都为空时查的是全表。而在多租户环境下全表其实不是全表因为租户插件会帮你强制加限制条件所以哪怕条件全空也查不出别人家的数据这一点新手可以放心。不过前提是这张表确实加了 tenant_id 字段并且租户插件没有被忽略。4.4 前端页面的对接菜单路由与按钮权限码后端接口写完前端页面也要跟上。若依 Vue3 版本的菜单管理每个菜单项可以关联一个路由地址和权限标识。比如设备列表页面的菜单路径可以设为/device/list权限标识设为device:list:query新增按钮的权限标识为device:list:add。在路由层面如果用的是微服务版的前端网关转发路由的时候会通过模块前缀来定位服务。例如浏览器请求/prod-api/device/list网关匹配到前缀/device的设备路由就转发到ruoyi-device这个服务。所以你在网关通常是 ruoyi-gateway配置里要加上类似的路由项spring: cloud: gateway: routes: - id: ruoyi-device uri: lb://ruoyi-device predicates: - Path/device/** filters: - StripPrefix1不同的网关版本配置会有所差异但思路一致给前端提供入口再由网关负载均衡到具体的子模块服务。如果不配置这步前端页面调接口时直接 404而且你从浏览器 Network 面板里看到的是网关返回的错误原因是找不到路由规则。5. 从网关到子模块的链路打通Feign 调用与跨模块鉴权5.1 Feign 接口的创建与扫描范围设置在多租户微服务版里模块之间经常需要互相调用数据。比如设备模块可能要去 system 模块查一下用户信息这时候就要用 Feign。若依的 Feign 接口一般放在被调用模块的api包内。比如 system 模块中会有FeignClient(contextId remoteUserService, value ruoyi-system) public interface RemoteUserService { GetMapping(/inner/user/info/{userId}) public UserInfo getUserInfo(PathVariable(userId) Long userId); }注意这里contextId要唯一否则多个 Feign 接口会被注册成同一个 Bean 名称启动时直接冲突报错。value指向被调用服务的spring.application.name。而调用方也就是我们的设备模块引入 system 模块的 API 依赖然后在自己的启动类上标注EnableRyFeignClients它就会扫描所有模块中定义的 Feign 客户端并生成代理对象。5.2 请求头里的租户上下文是怎么传递的这里有一处很关键的内部机制网关认证完用户后会把租户 ID 放在请求头中称为tenant-id。子模块在处理请求时有对应的过滤器读取这个请求头把它存进 ThreadLocal。当子模块通过 Feign 调用另一个子模块时如果只是普通调接口头信息不会自动带过去需要显式地传递否则被调用的服务就不知道当前是哪个租户。若依框架里已经有一个内部的InnerAuth注解和对应的拦截器专门用于模块间调用鉴权。团队内部调用时通过 Feign 请求内部接口要带上Authorization头这样被调用的服务能识别这是一次可信的内部调用。这一点是最容易出问题的。很多人在子模块 A 中通过 Feign 调用子模块 B 的接口怎么查都查不到数据排查到最后发现 B 服务收到请求后租户还是默认的000000这明显就是头信息丢失了。所以写 Feign 调用时要么使用若依提供的 RequestInterceptor 让它自动传递请求头要么自己写一个 Feign 配置把当前请求上下文中的所有 header 复制到新的请求上。5.3 内部接口隐藏与暴露的边界在多租户模块化架构下接口分两类一类是对前端开放的走网关另一类是内部模块间调用的不走网关。内部接口的路径前缀通常是/inner/*并使用InnerAuth(value false)这样的注解标识。这个设计是为了防止前端直接访问内部接口绕过权限校验。如果你在子模块中创建新接口需要明确它是内部接口还是外部接口。外部接口的 URL 不要包含内部关键字否则网关或安全配置会把它拦截这也算是个常见小坑。6. 我踩过的那些坑子模块开发真实问题排查记录6.1 启动时报“无法加载主类”或者“Mapper XML 不存在”这种情况大多出现在依赖没有引入或打包时资源没被扫描。第一件要做的事是检查目录结构是否和 Maven 标准一致。如果你在 IDEA 里右键创建文件夹没有选“Sources Root”代码目录不会被识别成源码目录运行时就可能出现这种报错。解决方法是右键src/main/java目录选择 “Mark Directory as Sources Root”同理resources目录要标成 “Resources Root”。这属于最基础的工程配置问题但在微服务多模块项目中反复发生的概率极高因为你新建模块后 IDEA 自动识别的动作不一定准确。6.2 接口查到的数据跨租户了到底是谁的锅有一次我在一个测试环境里发现设备管理员账号能查到其他租户的设备数据。排查过程很有意思。第一反应是租户插件没生效但检查了公共依赖确实引入了。后来发现自己写的 Mapper XML 里用了一个自定义 VO 表关联查询把两张表做 join而租户插件在某些场景下只会在主表上拼接租户条件副表可能会被遗漏。若依租户插件的实现有强制忽略某些表的功能可以通过配置ignore表名单的方式来处理。如果你的关联查询涉及副表注意要给副表加租户过滤条件或者考虑单独写关联查询而不是依赖自动插件。那次之后我在代码评审里加了一条要求凡是多表 join 的 SQL必须主动带上tenant_id条件不能完全依赖插件。6.3 子模块接口 401排查了半小时发现是网关白名单没配前端访问新模块的接口时网关默认是需要认证的。若依网关有一个白名单配置比如登录接口、验证码接口都在白名单内。如果你的新模块接口需要登录后才能访问那不需要配白名单。但如果你在内测阶段想临时放个别接口或者开发环境希望省去登录流程就需要在网关的配置中加白名单路径。注意白名单配置修改后要重启网关服务或刷新配置Nacos 配置模式下如果用RefreshScope动态刷新可能还需要再验证一下是否真正生效。我那次把配置改了但没刷新到网关实例一直还是 401最后发现是网关机器有两台我只改了其中一台 Nacos 上的配置后来手动重启才生效。6.4 Nacos 配置中心的命名空间与分组不小心的代价子模块如果采用 Nacos 作为配置中心bootstrap.yml里要指定namespace和group。不同的环境开发、测试、生产通常对应不同的命名空间。新模块的配置在 Nacos 上创建时文件名有讲究。一般格式是${spring.application.name}.yml。比如说ruoyi-device.yml。如果你创建配置时写成了device.yml应用启动时会提示找不到配置或者加载了空白配置导致数据库地址没初始化然后启动失败。那段时间我观察到团队里多个模块出现过同一个问题所有人都在本地环境跑得非常顺利一到测试环境就报数据源无法连接。原因是在 Nacos 上配置文件名和spring.application.name不一致。解决思路很简单把 Nacos 上的 Data ID 改成和spring.application.name一样的值。6.5 数据权限与租户数据权限的区别不要混为一谈很多初学者会把若依的“数据权限”和多租户的“租户隔离”混在一起。简单区分数据权限是做用户级别的比如同一个部门看同一批客户不同角色只能看自己部门的。这是通过 Data Scope 相关注解实现的。而租户隔离是租户级别的比如租户 A 永远看不到租户 B 的订单。这两个机制会叠加生效。在创建子模块时如果你的业务表既需要租户隔离又需要部门数据权限那么业务代码中要同时保证租户字段由插件自动维护数据权限字段如dept_id需要你自己在 SQL 中拼接条件。不要指望租户插件把部门条件也一起干了。我在很多次代码评审里都看到有人把dept_id误写成了tenant_id逻辑完全反了处理这种问题要格外小心。7. 扩展建议从单模块到多模块一个完整的业务落地路径7.1 规划阶段先画清楚模块边界别等代码写了一半再拆创建新模块之前先问自己几个问题这个业务和现有模块有没有较强的数据关联是否需要被多个模块调用是否需要独立部署例如设备管理如果只是给系统内部用数据上跟系统模块强关联那么也可以先放在 system 模块里。但如果设备管理将来要开放 API 给第三方系统调用还要承载较大的并发量那就值得独立成模块。模块边界划分不能纯粹按兴趣来要以部署运维的便利度和效率为基准。7.2 代码生成器的起点如何在已有模块上生成新业务若依多租户版的代码生成器通常会放在generator模块里执行时通过指定包名可以生成 controller、service、mapper、前端页面等。对于新创建的子模块代码生成器要重新生成这些代码时你需要在生成器的配置里设置好包路径前缀比如com.ruoyi.device。如果配置不匹配生成的代码包名还是旧的com.ruoyi.system那你就得手动把类文件搬到新模块目录里改造起来费时费力。所以我的习惯是创建好新模块后第一件事是把代码生成器的配置模板改好然后拿一张测试表跑一遍生成流程确认生成的代码能正常在新的模块中启动再去写真正的业务。7.3 数据库结构与 Redis 缓存等公共设施子模块该怎么接入子模块除了数据库之外往往还需要 Redis 缓存。若依封装了 Redis 工具类可以在子模块中直接引入但要注意模块中要配置 Redis 连接信息。如果不配RedisTemplate初始化虽然可能不报错但真正调用时会连接超时。此外若依的菜单权限和字典缓存都会走 Redis子模块如果启用了缓存注解但 Redis 没配对接口响应会异常缓慢那是因为连接超时后重试排队导致的。7.4 部署层面的独立性与统一网关的配合一个用于生产的子模块独立部署之后网关需要感知它的存在。使用 Nacos 注册服务时spring.application.name就是注册到注册中心的名称。确认多个模块之间不会出现同名注册项。一个容易忽略的部署细节是子模块的日志配置是否独立。建议每个模块有自己的日志文件目录不要所有模块往同一个日志文件里写。否则排查问题时日志互相交叉用户标识又没打进去最后只能靠时间猜效率极低。我在生产环境就吃过这个亏。8. 从零开始的检查清单照着做基本不会错写到这可以浓缩出一张实用清单。每新建一个若依多租户版子模块按下面顺序逐项核对父模块ruoyi-modules/pom.xml中是否声明了新模块的module。子模块pom.xml是否引入了ruoyi-common-mybatis、ruoyi-common-security、ruoyi-common-datasource、ruoyi-common-log。子模块启动类是否标注SpringBootApplication和EnableRyFeignClients。application.yml是否配置服务名、端口、数据源、MyBatis Mapper 路径。数据库表是否包含tenant_id字段。Mapper 接口是否能被组件扫描到。网关是否配置了对应的路由规则。Nacos 上是否创建好了与spring.application.name匹配的配置文件。前端菜单是否绑定了模块的接口路径和权限标识。跨模块 Feign 调用时请求头是否传递了租户信息。这张清单是我跑完第一个完整子模块后整理的现在团队里新成员上手时我都直接发给他们对照操作。省下来的沟通成本非常可观。实际上在若依多租户版里创建子模块说到底是一个工程结构微缩的过程。你要理解模块间的边界也要理清 Maven、网关、MyBatis 注入和租户拦截这条链路。框架本身提供了大量公共能力真正需要你写的业务代码占比并不高。只要把关键配置做到位新模块的开发效率和单体版本几乎一样快但它带来的部署灵活性和业务隔离性却是一个质的提升。最后分享一个个人习惯我会在新模块的根包下放一个module-info.md文件写清楚这个模块的职责、依赖了哪些公共模块、初始化表结构都有什么甚至把常见启动报错和解决方式也记一笔。这个习惯帮我省了不少查询代码上下文的时间。你在踩坑的过程中积累的经验如果只是放在脑子里下次还会踩把它固定在代码仓库里才真正变成了资产。