
1. 开始前必须理解的工程结构与租户模型1.1 modules目录里到底放了什么用若依做过二开的人大概都有印象单体版什么都在一个工程里前后端分离版是ruoyi-admin、ruoyi-system、ruoyi-framework这几个模块到了 Cloud 版代码被拆成了ruoyi-gateway、ruoyi-auth、ruoyi-modules、ruoyi-visual等几大块。我们要聊的就是ruoyi-modules它是微服务架构下的“业务模块集中营”里面默认躺着ruoyi-system系统管理、ruoyi-job定时任务、ruoyi-file文件服务、有的版本还有ruoyi-gen代码生成和ruoyi-demo示例。每个业务模块的结构高度一致一般是api子工程加业务工程两层。api子工程专门放对外暴露的 Feign 接口、DTO 和常量业务工程里才是真正的 Controller、Service、Mapper。这样做的好处是别的模块要调你的服务只需要依赖ruoyi-xxx-api不用把你的 Mapper、Service 全拖过去微服务之间的调用链干净得多。所以“在 modules 中创建子模块”这件事本质上是两件事第一在 Maven 层面追加一个业务工程并注册到父级第二让这个业务工程具备若依微服务模块该有的“基础设施”包括 Nacos 注册、配置中心拉取、多租户拦截、统一鉴权、网关路由。这两件事没做齐模块就跑不起来。1.2 多租户在若依里是怎么实现的多租户听起来很高大上落到若依这套代码里核心机制其实不复杂每个需要租户隔离的业务表都带一个tenant_id字段MyBatis 的拦截器会在执行 SQL 的时候自动追加WHERE tenant_id ?把当前登录用户所属的租户 id 拼进查询条件里。这个“当前租户 id”从哪来前端登录后会把租户信息存起来后续请求带过来网关和认证中心解析后塞进上下文业务模块再从中取。偷懒点说你在若依多租户版里建表时只要注意三件事加tenant_id字段、建好租户字段的索引、实体上继承或标注租户逻辑。代码层面的隔离MyBatis 拦截器基本替你干完了。这也是为什么一模一样的一套代码一个租户登录进去只能看到自己的数据——不是靠业务代码里写死WHERE而是框架层在做统一过滤。不过拦截器并不是对所有表都生效。系统级别的表比如sys_menu、sys_config、sys_dict_type这些不能做租户隔离否则每个租户登录后连菜单都加载不出来。所以若依提供了忽略表配置在配置文件里通过tenant.ignore-tables列出来拦截器碰到这些表就直接跳过。你新建模块时如果业务表不需要租户隔离也把它加到这个忽略清单里。1.3 为什么新业务必须放进 modules而不是在 ruoyi-system 里硬塞我见过不少人图省事把业务代码直接往ruoyi-system里塞controller 和用户的 controller 放同一个包觉得少建一个模块少很多麻烦。短期看是省事了长期看全是坑。第一个坑是职责混乱。ruoyi-system管的是用户、角色、菜单、字典这类基础数据属于框架级能力。你往里面塞“订单管理”“设备台账”这种业务功能系统模块会越滚越大每次升级若依版本时冲突一大堆别人接手也看不懂哪些是框架的、哪些是你们自己加的。第二个坑是发布粒度。微服务最大的价值之一就是独立部署、独立扩容、故障隔离。如果所有业务都塞进 system 服务哪怕你只是改了个订单查询也得把整个 system 服务重新发一遍出问题影响面也会扩大。拆成独立子模块后新模块的发布、回滚、限流都能单独控制。第三个坑是团队协作。多人开发时大家往同一个模块提交代码合并冲突会非常频繁。而独立模块就是天然的代码边界每个人负责自己的模块互不干扰。所以只要你的团队规模超过两三个人或者业务线比较独立我都建议规规矩矩在modules下新建子模块而不是“先塞进去再说”。2. 新建子模块前要定好的三件事2.1 命名包名、服务名、路由前缀怎么统一建模块之前先把名字定下来免得后面改来改去。我的习惯是业务用英文名作为模块名例如设备管理就是ruoyi-device包名com.ruoyi.deviceNacos 服务名ruoyi-device网关路由前缀/device/**。四个地方的命名保持同一个词根调试时不会被绕晕。这里有个容易踩的细节若依的网关路由配置里predicates 路径最好和服务名弱相关但不要完全无关。比如你服务名是ruoyi-device网关路由却写成/equipment/**前端联调时你就得一直惦记这个映射关系时间长了必然有人记错。另外多模块场景下模块名的ruoyi-前缀建议保留。这是若依的约定Nacos 服务列表里能一眼识别出哪些是框架服务、哪些是业务服务。网关配置、权限配置、日志系统里也都默认按这个前缀做了不少约定你换个前缀就得手动处理一堆隐藏逻辑。2.2 模块拆不拆 api什么时候需要 ruoyi-xxx-api新建模块时第一步就得决定只建一个业务工程还是业务工程之外再建一个ruoyi-xxx-api。判断标准很简单有没有别的模块需要调用你提供的接口。最典型的场景是工作流模块要调“用户模块”查审批人信息或者“订单模块”完成后需要通知“库存模块”扣减库存这些都属于跨模块调用。既然要跨模块被调方就需要提供一份“给外部看的接口契约”也就是 Feign 接口。这份契约放在api子工程里调用方依赖它被调方实现它两边只通过接口通信不直接依赖对方的数据库和内部服务。如果你确定这个模块做出来就是独立运行短期内没有其他模块会调它那可以先不建 api 子工程只建业务工程。但以我自己的经验凡是正经做企业级项目的模块最后几乎都会遇到跨模块调用需求。与其后面再拆分重构不如第一次就按“api 业务”的双工程结构建好api 里先放一个空接口占位也没关系后面补实现成本低很多。2.3 表结构与租户字段设计建表是用代码生成器直接从数据库生成代码还是在工程里手写建表 SQL我的建议是先用 SQL 把表设计好再用若依的代码生成工具生成代码。核心原因在于代码生成器读的是表结构你表设计得越规范生成的代码质量越高。多租户表的字段有几个约定俗成的标配主键id、租户字段tenant_id、审计字段create_by、create_time、update_by、update_time、逻辑删除标志del_flag。只要你的表带上了这些字段生成的实体、Mapper、Service 基本都能自动沿用若依的 BaseEntity 和 BaseMapper 体系。还有个小细节值得注意tenant_id字段的类型若依常见版本里是bigint也有版本用varchar存字符串。不管哪种新建表时一定要和你当前使用的若依版本保持一致否则拦截器拼接 SQL 时类型对不上轻则查不到数据重则直接报错。我的建议是看一眼你们当前项目里sys_user表的租户字段类型然后照抄。3. 手把手从空目录到可调用的完整步骤3.1 在父工程中注册 module 并配置依赖假设我们要新建一个“示例教学”模块模块名定为ruoyi-demo。先在ruoyi-modules/pom.xml里把新模块注册进去modules moduleruoyi-system/module moduleruoyi-demo/module /modules同时确认父工程pom.xml的dependencyManagement里是不是已经引入了新模块的依赖坐标。如果没有先加进去比如dependency groupIdcom.ruoyi/groupId artifactIdruoyi-demo/artifactId version${ruoyi.version}/version /dependency这里要注意版本号统一用项目里已有的${ruoyi.version}不要自己手写一个版本号否则依赖版本冲突排查起来很头疼。然后在ruoyi-modules目录下新建两个 Maven 工程ruoyi-demo-api和ruoyi-demo。如果你的场景暂时不需要对外提供接口可以只建ruoyi-demo一个工程。API 子工程的依赖很简单一般只依赖ruoyi-common-core业务工程的依赖则典型一套ruoyi-common-security、ruoyi-common-mybatis、ruoyi-common-redis、ruoyi-common-log、ruoyi-demo-api。照抄ruoyi-system的 pom 做删减是最不容易错的方式。3.2 编写启动类与 bootstrap 配置业务工程的入口类长这样package com.ruoyi.demo; 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.demo.mapper) public class RuoYiDemoApplication { public static void main(String[] args) { SpringApplication.run(RuoYiDemoApplication.class, args); } }EnableDiscoveryClient是必须的没有它服务不会注册进 Nacos。MapperScan则要保证包路径写对很多新模块启动后报“找不到 mapper”十有八九是这里扫描路径配错了或者 Mapper 接口所在的包不在扫描范围里。接下来是配置文件这里有个若依 Cloud 版最容易搞混的地方本地的application.yml基本都是裸配置真正的数据源、Redis、Nacos 地址、租户开关、日志级别这些全放在 Nacos 配置中心的“服务名.yaml”文件里本地的bootstrap.yml只负责告诉应用“你去哪个 Nacos 拉配置”。所以新建模块后你必须登录 Nacos 控制台在配置管理里手动新增一个ruoyi-demo.yaml配置把以下内容填进去spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/ry-cloud?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneGMT%2B8 username: root password: root mybatis-plus: mapper-locations: classpath*:mapper/**/*Mapper.xml type-aliases-package: com.ruoyi.demo.domain tenant: enable: true ignore-tables: - sys_user - sys_menu - sys_config - sys_dict_type端口号建议在现有模块端口号段之外顺延。比如你的ruoyi-system是 9201、ruoyi-file是 9300那新模块可以分配 9401 这种在 Nacos 配置文件里用server.port指定。如果和别的服务端口撞了启动时会报端口占用排查起来非常明显所以不用太担心但提前规划好端口分段会省很多事。3.3 编写第一个带租户隔离的业务接口工程骨架有了接下来写一个最简单的带租户隔离的 CRUD。表结构可以先用现成的业务表做测试也可以用下面这种简化的学生表CREATE TABLE demo_student ( id bigint not null auto_increment comment 主键, tenant_id bigint default null comment 租户ID, student_name varchar(50) not null comment 学生姓名, class_name varchar(50) default null comment 班级, create_by varchar(64) default comment 创建者, create_time datetime default null comment 创建时间, update_by varchar(64) default comment 更新者, update_time datetime default null comment 更新时间, del_flag char(1) default 0 comment 删除标志, primary key (id) ) engineinnodb comment示例学生表;实体类继承若依的BaseEntity并在租户字段上标注TableField映射。注意 MyBatis-Plus 的租户拦截器是靠着tenant_id字段名做匹配的你在实体里不要把这个字段改名成别的否则拦截器拼 SQL 时会找不到列。代码生成器如果手动生成的话认准默认的 domain 模板就行。Controller 部分参考ruoyi-system里的写法Controller 继承BaseControllerService 实现类里调用startPage()和getDataTable()这套若依封装好的分页逻辑再在需要权限的接口上标注PreAuthorize(ss.hasPermi(demo:student:list))。支撑这套注解的鉴权组件在ruoyi-common-security里这也是刚才强调依赖不能省的原因。3.4 注册 Nacos 与网关路由启动新模块后去 Nacos 的服务列表里应该能看到ruoyi-demo。看不到就先检查是不是EnableDiscoveryClient没加或者 Nacos 地址配置错了。服务注册好了网关转发还没配上前端请求依然进不来。在ruoyi-gateway模块的配置文件里增加路由spring: cloud: gateway: routes: - id: ruoyi-demo uri: lb://ruoyi-demo predicates: - Path/demo/** filters: - StripPrefix1路由id要全局唯一uri用lb://前缀表示走 Nacos 负载均衡核心对应关系是前端只要发起/demo/xxx的请求网关就会把它转发给ruoyi-demo服务。StripPrefix1表示转发时去掉第一级前缀也就是请求/demo/student/list经过网关后后端收到的实际路径是/student/list。如果你的 Controller 映射路径本身带了/demo那这行过滤器就去掉否则会多剥一层路径导致 404。网关配置改完需要重启网关服务不是热加载就能生效的。这一点每次都要跟同事强调十次有五次排查路由不生效最后发现是网关没重启。3.5 前端菜单与按钮权限接入后端接口通了还要让前端能进到这个模块的页面。若依前端的菜单是后端返回的动态渲染所以要走完一套“菜单初始化”的 SQL。这里我给个可以直接执行的思路先查sys_menu表把自己新模块目录菜单的menu_id记下来然后插入子菜单。核心字段包括menu_name菜单显示名称parent_id父菜单 IDorder_num排序号path前端路由路径例如democomponent前端组件路径例如demo/student/indexmenu_typeC代表菜单F代表按钮perms权限标识必须和PreAuthorize里的字符串一致例如demo:student:listvisible显示状态0表示显示status菜单状态0表示正常前端src/views/demo/student/index.vue页面要真实存在否则菜单点进去白屏。组件路径写错是最常见的前端问题排查时先看浏览器控制台有没有报“找不到组件”的错。4. 多租户隔离的验证方法与常见翻车现场4.1 如何验证租户过滤真的生效了很多同学建完模块用超管账号登录看到数据是正常的就以为租户隔离没毛病。这个验证方式是有问题的因为超管账号在某些若依版本里会跳过租户过滤你根本测不出真实效果。正确姿势是建两个租户分别在两个租户下各建一条数据然后用一个非超管的租户管理员账号登录看它是否只能看到自己租户的数据。如果能看到别的租户的数据说明拦截器没有生效优先检查表里有没有tenant_id字段、租户开关是不是true、实体上有没有丢租户标注。再补一刀打开后端日志把 MyBatis 打印出来的 SQL 捞出来看确认是不是自动拼了AND tenant_id ?。如果 SQL 里没有这个条件说明拦截器压根没走到问题在配置如果条件有了但查出的数据还是不对那问题就在数据本身比如某条数据tenant_id存了 0 或者 NULL。4.2 常见问题速查表我把这几年在若依多租户模块创建中遇到的高频问题整理了一下直接给结论症状常见原因解决办法启动报“找不到 Nacos 配置”Nacos 上没创建对应的 yaml 配置登录 Nacos新增服务名.yaml并检查 bootstrap.yml 的 namespace/group 是否匹配服务启动成功但 Nacos 列表里没有启动类缺EnableDiscoveryClient补上注解并重启接口报“找不到 Mapper”MapperScan路径没覆盖到 mapper 包修改扫描路径为com.ruoyi.xxx.mapper或直接在 Mapper 接口上标注Mapper分页不生效或列表不显示Controller 没继承BaseController确认继承关系并调用startPage()再查询查询能通但能看到别人租户数据业务表缺tenant_id或拦截器忽略表配置误伤给表补字段检查 tenant.ignore-tables 是否包含该表前端菜单能打开但接口 401权限标识不一致核对 sys_menu.perms 与 PreAuthorize 里的标识网关请求 404路由没重启或 StripPrefix 层级不对重启网关检查路由断言与过滤器层级前端白屏报找不到组件component 路径写错或目录不存在修正 sys_menu.component 字段确认前端文件存在跨模块 Feign 调用报无权限或租户丢失缺少租户上下文透传确认 Feign 拦截器已配置必要时手动传递租户 id表里列的问题我基本都踩过一遍。其中最隐蔽的是最后一个跨模块调用时的租户上下文传递。4.3 复杂场景Feign 跨模块调用时的租户传递假设ruoyi-demo模块需要调用ruoyi-system的接口查用户信息在请求传递过程中租户 id 必须跟着请求头传到下游服务否则下游查询时不知道当前是哪个租户SQL 里tenant_id参数就是空的要么查错数据要么直接被拦截器挡掉。若依自己的 Feign 拦截器在ruoyi-common-security里正常配置时它会自动把请求头里的租户信息转发到下游。但如果你用了Async异步线程、或者手动 new 了一个 Thread 去发起调用上下文传递就会断掉。遇到这种情况最稳妥的做法是在调用前手动从上下文中取出tenantId拼进请求参数或新线程的上下文中。还有一类情况是跨模块回调场景比如订单模块处理完成后通知库存模块库存模块又回调订单模块查状态这种链路一长任何一环丢失租户上下文后面的查询全乱套。我的经验是在模块入口做一个租户 id 的过滤器统一从请求头解析并放入当前线程变量而不是依赖分散在业务代码里的各种赋值。这样至少可以保证“进来的请求都带租户”出问题最多是起点的源头丢了租户追溯起来好查很多。5. 版本差异与扩展建议5.1 若依 plus、Vue3、TS 版本需要注意什么现在很多人用的是ruoyi-vue-plus或者自己改造过的 Vue3 TypeScript 版本这些版本在模块创建上跟经典版有差别但核心逻辑没有变。Plus 系列把系统管理拆得更细MyBatis-Plus 的租户插件默认是开启的也是靠TenantLineInnerInterceptor拼 SQL只不过配置项名字可能不同而且部分版本默认不开启租户需要自己确认一下配置。Vue3 TS 版的前端在做菜单路由时常见一个坑meta类型推断报 TS 错误比如meta.title提示可能为 undefined。这是因为动态路由表没有定义好类型接口。解决方式是在src/router相关位置找到路由元信息接口把title、icon、hidden等字段声明成可选项即可。MES、ERP 这类企业级项目需要跟第三方服务集成时我建议还是保持“若依只做管理和鉴权重业务逻辑放独立服务”的模式。下面这种架构在项目里已经被验证过很多次若依负责用户、组织、菜单、权限、审计独立 Python 或 Go 服务负责图像识别、算法计算这种高频高算力任务两者通过 HTTP 或消息队列通信Python 服务从若依的接口获取认证信息业务数据各自落库必要时通过定时任务或事件回调同步。这样两边技术栈都能发挥优势若依的更新升级也不会被业务算法牵连。新模块要接入这套架构不需要改模块本身只需要在新增业务模块里多写一个 Feign 接口暴露给独立服务调用或者在网关层做路由转发即可。5.2 多租户的三种隔离级别为什么默认选了行级隔离建模块前还有必要想清楚一个问题你需要的多租户是行级隔离、库级隔离还是 Schema 级隔离。若依默认的是行级隔离也就是所有租户共用一张表靠tenant_id区分数据。这是一个成本和隔离性平衡后的选择维护简单、升级方便、硬件资源占用小。但如果你的客户有强合规要求比如财务数据、医疗数据必须物理隔离行级隔离就不够用了。这时候需要把动态数据源路由接进来让每个租户对应独立的数据库框架根据当前租户 id 路由到不同库。代价是连接管理、建库流程、备份恢复都会复杂不少。我的建议是除非客户明确要求物理隔离否则不要一上来就上库级隔离先用行级隔离把业务跑通等项目成熟了再演进。多租户模块的代码结构在这三种隔离模式下差别不大核心都是“根据租户 id 确定数据范围”只是在实现层面从 MyBatis 拦截器换成了数据源路由。5.3 配置多模块并行开发的小经验最后分享一个团队协作层面的经验。如果你所在的团队有多个人同时开发不同模块建议在 modules 下的每个业务模块里单独维护自己的 mapper XML 目录和 controller 包不要跨模块共用。很多团队喜欢把公共的查询逻辑抽到一个 common 包起初没问题但模块一多common 包就变成了大杂烩改一个公共方法可能影响所有模块发布时又得把全部模块重新走一遍流水线。更推荐的做法是每个模块内部建立适合自己业务的分层跨模块真正通用的内容通过 api 接口调用而不是直接把对方的 service 类拿来用。模块间依赖关系越弱编译和发布效率越高。这也是微服务架构里“模块自治”的朴素实现。我在实际项目中见过太多因为“图省事”导致的隐性耦合比如订单模块的 Service 直接注入库存模块的 Mapper早期单体部署时没什么问题一拆微服务库存模块独立扩展了订单模块引用它的 Mapper 就出问题。坚持模块边界短期看是多写了一个 Feign 接口长期看省的是整个团队的调试时间。写在最后多租户 modules 子模块的创建本质上并不难难点在于理解若依这套代码里微服务、鉴权、多租户三条线是怎么交织起来的。只要把服务注册、配置中心、网关路由、租户字段这四件事理清楚后续每个新模块都能按同样的套路复制。我个人建议第一次建模块时不要从零手写 pom 和启动类直接复制一个现有模块比如ruoyi-demo或ruoyi-file全局替换包名、模块名、服务名再删掉用不上的业务代码。这样能保证公共依赖一个不落配置风格也和项目保持一致。等新模块顺利跑通了再往里填自己的业务逻辑。最后再提一个很多人容易忽略的点新模块建好后先在测试环境完整走一遍“建租户、建用户、分配菜单、登录、增删改查、切换租户验证隔离”的流程不要只验接口通没通。多租户系统的核心价值是数据隔离接口通不等于隔离正确只有用两个租户的数据实际对比过才算真正完成了一个子模块的接入。