模块化框架设计与自动服务注册:用Java打造轻量级依赖注入容器 📅 发布时间:2026/9/13 2:51:49 👁 浏览次数: 做后端这些年“模块化”这三个字几乎每隔一阵就会被翻出来讨论一次。服务越拆越多、代码越来越乱手动new对象、if-else 分发、复制粘贴注册逻辑这些事我踩过太多次。今天聊的这套框架核心只有两件事模块化的代码组织以及自动服务注册。它不算重但解决了团队里真正棘手的几个问题——模块边界模糊、新增服务要改动注册代码、接口与实现强耦合。如果你也被模块之间互相 import、改一个功能牵一发动全身、每加一个服务就要改一遍注册类这些事折磨过这篇文章应该能给你一个轻量、可落地的答案。就算你不想从零写框架把里面的扫描、注册、注入思路抄到项目里也能明显改善代码结构。1. 为什么还需要一个自己的模块化框架1.1 从实际问题出发看看“手动注册”有多痛先说一个我经常遇到的场景项目刚开始是单体应用一个后台管理系统的代码全部塞在一个模块里Controller、Service、Mapper 分层清晰日子还算好过。但业务增长之后来了支付、订单、用户、消息推送好几个大模块项目开始拆 Maven 模块问题立刻出来了。模块 A 需要调用模块 B 的服务于是 A 直接依赖 B 的实现类。今天 B 换了一个实现方案改了个类名A 的代码也要跟着改。更麻烦的是模块之间的依赖关系没有收口A 依赖 BB 又依赖 CC 居然反过来依赖 A编译期看着没什么运行起来偶发类加载问题排查到凌晨也是常有的事。手动注册的痛点就更具体了。早期我给项目写过一个ServiceRegistry里面是密密麻麻的Map.put(orderService, new OrderService())。每新增一个服务就要打开这个注册类加一行改一行最后这个注册类膨胀到几百行全是机械性的复制粘贴。一旦有人忘记注册运行时直接空指针报错信息还特别隐晦指向业务代码而不是注册表。这些问题的根源不是代码写得差而是缺少一层“模块边界 自动装配”的基础设施。手动注册本质上是在维护一张服务清单清单一旦和实际代码脱节就是无穷无尽的运行时问题。1.2 模块化框架与微服务注册中心的区别很多人一听“自动服务注册”第一反应是 Nacos、Eureka 那套微服务注册中心。这里必须把概念先理清否则后面代码看不懂。微服务注册中心解决的是跨进程的服务发现服务 A 部署在机器 1服务 B 部署在机器 2A 怎么知道 B 的 IP 和端口这是网络层的问题。而本文说的模块化框架里的“自动服务注册”是进程内的问题一个 JVM 进程里有多个模块每个模块对外暴露一些服务接口框架自动找到这些接口的实现类实例化之后放进一个统一容器里。调用方只需要声明“我要用某个接口”框架把对应实现注入进来。用生活化类比解释一下微服务注册中心像是外卖平台的商家列表你输入“黄焖鸡”平台告诉你附近哪几家有卖、地址在哪。模块化注册框架则像是你家厨房里的调料架酱油、醋、盐都摆在固定位置做饭的时候伸手就拿不用每次都翻箱倒柜找。两者解决的是不同层次问题可以共存也可以只用其中一个。如果是单机应用只需要进程内的自动注册就够了如果拆成了微服务进程内这套框架依然可以作为每个服务内部的“模块管理基础设施”。2. 模块化设计与服务注册的整体思路2.1 模块即包结构一个模块一个根包这套框架对“模块”的定义非常简单一个模块就是一个独立的 Java 包加上一张描述模块信息的注解。包名即模块边界比如com.example.module.order是订单模块com.example.module.message是消息模块它们之间不允许直接 import 对方的实现类只能通过接口通信。模块的元数据我用注解来表达而不是 XML 配置文件。原因有两个第一注解和代码待在一起不会被配置文件漂移问题坑到第二现代 IDE 对注解的跳转和重构支持比 XML 好太多改动类名、接口名时注解里的引用会自动更新XML 则经常漏。模块注解长这样Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface Module { String name(); String version() default 1.0.0; String[] dependencies() default {}; }name是模块的唯一标识version用于版本管理和启动时的版本冲突检测dependencies声明模块依赖的其他模块名。这个 dependencies 不是强制 Maven 依赖而是框架在启动时校验依赖的模块必须存在且已注册否则启动直接失败。这比运行到一半才报 ClassNotFoundException 要友好得多。工程结构上我推荐每个模块维护自己的api子包和internal子包。api包放对外暴露的接口和模型其他模块只允许依赖这部分internal包放实现类和各种内部工具类不对外公开。这个约定不一定靠编译器强制但代码评审的时候一眼就能看出来谁违反了规范。2.2 自动注册的核心流程整个自动注册流程可以拆成四个阶段扫描、解析、注册、注入。扫描阶段框架拿到一个需要扫描的包根路径比如com.example.module然后递归找到这个包下的所有 Class。这里不是扫描整个 classpath而是只扫描配置的模块包避免把 Spring、第三方库的类全部卷进来。解析阶段框架遍历扫描结果检查 Class 上是否标注了Module或ServiceProvider注解。如果是Module记录模块名、版本、依赖如果是ServiceProvider提取接口类型、实现类类型、服务名等关键信息。注册阶段框架把解析出来的服务按“接口名 - 实现类”的关系放进注册表。这个注册表本质上就是一个ConcurrentHashMapString, ServiceDefinition没有太多玄机。真正需要设计的是什么时候实例化我采用默认懒加载策略只有第一次被用到时才创建实例启动速度快也避免了一些配置无效导致的启动失败。注入阶段当一个模块里的服务被实例化之后框架会检查它的字段上有没有Inject注解如果有就从注册表里取出对应对象通过反射设进去。到这里一个完整的“服务发现 自动装配”闭环就算完成了。这套流程和 Spring 的 BeanFactory 启动流程很像但做了一些极简处理。比如没有复杂的 BeanPostProcessor 链没有 AOP 代理没有一堆 XML 命名空间。因为我想强调的是“够用就好”而不是再造一个 Spring。3. 关键实现写一个可用的自动注册核心3.1 定义注解模块、服务提供者、依赖注入框架最核心的三个注解是Module、ServiceProvider和Inject。前面已经展示了Module这里重点看后两个。Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface ServiceProvider { String value() default ; Class?[] interfaces() default {}; boolean singleton() default true; }value是服务注册名默认用实现类的简单名称首字母小写比如OrderServiceImpl会变成orderServiceImpl。interfaces字段用来显式声明这个实现类要对外暴露哪些接口不填的话默认取该类所有直接实现的接口。singleton控制实例模式默认单例因为大多数服务都是无状态或可共享的每次new反而浪费。Inject就简单得多标记在字段上框架完成注册后自动赋值Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface Inject { String value() default ; }value用于指定想要注入的服务名默认按字段类型查找。这个设计有一点需要注意反射注入只能访问非 private 字段吗不是setAccessible(true)可以突破访问限制但在 Java 17 之后的模块化系统里如果被注入的类在别的模块可能要加--add-opens参数。这也是大家用 Spring 时没感觉、自己写反射却遇到InaccessibleObjectException的原因遇到时不用慌JVM 启动参数里补上即可。3.2 扫描器实现怎么把包路径下的类找出来扫描是自动注册的第一步。我用纯 JDK 实现了一个简单的扫描器思路是获取包根对应的资源路径然后遍历文件系统找到所有.class文件并加载成 Class。public class ClassScanner { public static ListClass? scan(String packageName) throws IOException, ClassNotFoundException { String path packageName.replace(., /); EnumerationURL resources Thread.currentThread().getContextClassLoader().getResources(path); ListClass? classes new ArrayList(); while (resources.hasMoreElements()) { URL resource resources.nextElement(); String protocol resource.getProtocol(); if (file.equals(protocol)) { classes.addAll(findClassesInDirectory(new File(resource.toURI()), packageName)); } else if (jar.equals(protocol)) { classes.addAll(findClassesInJar(resource, packageName)); } } return classes; } }关键点有两个。第一一定要用Thread.currentThread().getContextClassLoader().getResources()而不是ClassLoader.getSystemResource()。在 Tomcat、Spring Boot 这类 Web 容器里线程上下文类加载器才是能真正看到应用自己类的那个系统类加载器经常拿到的是容器的基础类库。第二要同时处理 file 协议和 jar 协议。开发环境下 class 在 target/classes 目录是 file 协议打成 fat jar 跑生产时就是 jar 协议。两个分支都必须实现否则本地跑得好好的部署上线就扫描不到。还有一个容易被忽略的细节扫描到一个 class 文件后用Class.forName(className)还是contextClassLoader.loadClass(className)推荐后者。前者会执行类的静态初始化块万一静态块里有数据库连接等重量级操作在扫描阶段就直接把服务实例化了这违背了懒加载的初衷。loadClass只是把类文件加载进来不触发初始化。3.3 注册中心与依赖注入容器核心注册中心的代码不会很复杂但有几个设计决策值得展开。public class ServiceRegistry { private final MapString, ServiceDefinition services new ConcurrentHashMap(); public void register(ServiceDefinition definition) { String name definition.name(); if (services.containsKey(name)) { throw new DuplicateServiceException(Service already exists: name); } services.put(name, definition); } SuppressWarnings(unchecked) public T T get(String name) { ServiceDefinition definition services.get(name); if (definition null) { return null; } return (T) definition.getInstance(); } }ServiceDefinition内部持有实现类、接口列表、单例标记以及懒加载后的实例引用。getInstance()方法首次调用时同步创建实例并且会顺带做依赖注入public synchronized Object getInstance() { if (singleton instance ! null) { return instance; } Object obj createInstance(); injectDependencies(obj); if (singleton) { instance obj; } return obj; }这里synchronized只锁当前服务定义不会锁整个注册表并发性能能接受。线程安全上懒加载单例用synchronized方法实现最简单虽然会有一点性能开销但对于常规后端服务来说完全够用。注入的逻辑就是反射字段扫描private void injectDependencies(Object target) { for (Field field : target.getClass().getDeclaredFields()) { if (field.isAnnotationPresent(Inject.class)) { field.setAccessible(true); Object dependency registry.get(field.getType().getName()); if (dependency null) { throw new UnsatisfiedDependencyException( Cannot inject field.getType() into target.getClass()); } field.set(target, dependency); } } }没有循环依赖问题吗有。比如模块 A 的 ServiceA 注入了 ServiceBServiceB 又注入了 ServiceA按上面的写法会死循环或栈溢出。我采用的方案是两阶段初始化第一阶段先创建所有非单例且依赖复杂的对象的“空壳”实例不注入字段第二阶段统一注入。这样可以打破大多数循环依赖。如果你的场景出现了 A→B→C→A 的环第一反应不该是怎么绕过去而是重新审视模块划分——这种环说明模块边界没划清楚拆开比硬解更有价值。3.4 通过 SPI 机制让模块可以热扩展到这里框架已经能在启动时自动扫描并注册了但还有个问题如果新模块是后来打进 classpath 的想要不修改主程序代码就能被加载怎么办这就需要 SPIService Provider Interface机制。我约定了一个目录META-INF/modules/每个模块 JAR 包在自己的 META-INF/modules 下放一个描述文件文件名叫module.properties。主程序启动时用类加载器去扫描 classpath 下所有这个目录里的文件解析出模块名、入口类、依赖关系然后动态加载。public class ModuleLoader { public ListModuleMeta loadModules() throws IOException { EnumerationURL urls Thread.currentThread() .getContextClassLoader() .getResources(META-INF/modules/module.properties); ListModuleMeta modules new ArrayList(); while (urls.hasMoreElements()) { URL url urls.nextElement(); try (InputStream in url.openStream()) { Properties props new Properties(); props.load(in); modules.add(new ModuleMeta( props.getProperty(module.name), props.getProperty(module.version), props.getProperty(module.entry) )); } } return modules; } }有了这套 SPI 机制新增模块时只需要在 JAR 里放好描述文件主程序一行代码都不用改。这才是“自动注册”的真正价值——不是写代码的人少写几行new而是整个系统的扩展方式从“改代码”变成“加文件”。做插件化的基础也在这里。4. 接入一个 Spring Boot 项目实战4.1 在 Spring Boot 里并存不让两套容器打架我团队的项目是 Spring Boot 技术栈所以框架虽然可以做纯 Java 的容器但接入 Spring Boot 时要做一层桥接。很多人一听“自己写一套注册框架”第一反应就是“那不是跟 Spring 的 BeanFactory 重复了吗”实际上两套容器可以和平共处关键在分工Spring 容器继续管理 Controller、配置项、MyBatis Mapper、Spring Security 过滤器等基础设施 Bean。自研框架负责管理业务模块之间的服务注册与装配尤其是几个模块间解耦的部分。桥接方式是写一个Configuration类在 Spring 启动时同步触发框架的扫描注册Configuration public class ModuleFrameworkAutoConfig implements ApplicationContextAware { Value(${module.scan.base-package:com.example.module}) private String basePackage; private ApplicationContext applicationContext; PostConstruct public void init() throws Exception { ModuleRegistry registry ModuleRegistry.getInstance(); ClassScanner.scan(basePackage).stream() .filter(clazz - clazz.isAnnotationPresent(ServiceProvider.class)) .forEach(clazz - registry.register(parse(clazz))); } Override public void setApplicationContext(ApplicationContext applicationContext) { this.applicationContext applicationContext; } }这里通过ApplicationContextAware拿到 Spring 的容器引用但注意我的自研模块并没有把 Spring 的对象注入进去它管理的仍然是模块内部的服务。反过来如果 Spring 的 Controller 想拿模块框架里的服务我额外写了一个工具类FrameworkBeanProvider提供一个getBean(String name)静态方法Controller 在需要时手动获取或者在一个配置类中把这些服务手动声明成 Spring Bean方便直接Autowired。我建议别把所有服务都桥接成 Spring Bean否则两套容器的 Bean 互相引用会变得特别混乱。最干净的做法是框架服务之间完全由框架自己管理只有 Controller 层接一层薄薄的桥接。4.2 一个实际案例订单模块与消息模块举个例子。我们有一个订单模块负责创建订单、保存订单还有一个消息模块负责给用户发送短信、站内信。订单创建成功后需要触发消息通知按照这个框架两个模块的代码结构是这样的订单模块的接口public interface OrderService { void createOrder(OrderRequest request); }订单实现类ServiceProvider(interfaces OrderService.class) public class OrderServiceImpl implements OrderService { Inject private MessageService messageService; Override public void createOrder(OrderRequest request) { // 保存订单逻辑 System.out.println([OrderService] 保存订单: request.getOrderId()); // 发送通知注意这里依赖的是接口 messageService.sendMessage(request.getUserId(), 您的订单已创建); } }消息模块public interface MessageService { void sendMessage(String userId, String content); } ServiceProvider(interfaces MessageService.class) public class MessageServiceImpl implements MessageService { Override public void sendMessage(String userId, String content) { System.out.println([MessageService] 给用户 userId 发送消息: content); } }两个模块在代码层面没有任何直接依赖。OrderServiceImpl只知道MessageService接口存在不知道MessageServiceImpl是什么。真正把它们连接起来的是框架的注册表启动时扫描到两个ServiceProvider分别注册实例化OrderServiceImpl时发现Inject字段类型是MessageService从注册表查到对应实现反射注入。加一个EmailMessageService再注册进去订单模块一行不变就能动态换实现。运行时的验证方式也很直观启动日志里框架会打印注册清单[ModuleFramework] Registered service: messageService - MessageServiceImpl [singleton] [ModuleFramework] Registered service: orderService - OrderServiceImpl [singleton] [ModuleFramework] Injected dependency: OrderServiceImpl.messageService - MessageService看到这三行日志基本就能确认自动注册和注入都成功了。5. 常见问题与排查技巧5.1 扫描不到类注册列表为空这是最常遇到的问题。扫出来 0 个类注册表空荡荡。排查步骤按顺序来第一步确认包根配置对不对。都说的是com.example.module但实际实现类放在com.example.module.order.internal里只要前者是后者的前缀就没问题递归扫描能覆盖到。如果包根写成了com.example会扫到很多无关类但也不会报错性能会差一些。第二步检查代码是不是编译到了 target 或 classes 目录。用 IDE 跑测试的时候有时候增量编译没触发扫描器看到的是旧文件。第三步也是最高频的坑Fat Jar 里扫描失败。Spring Boot 打包后所有依赖 JAR 被合并进一个 BOOT-INF 结构通过普通 URL 拿到的file:路径是行不通的得用jackson等工具获取带嵌套路径的 URL或者干脆用ClassPathScanningCandidateComponentProviderSpring 自带的扫描器来处理 Spring Boot 环境下的扫描。这也是我后来在实际项目里采用的方案——自己不重复造轮子直接用 Spring 的扫描器做底层框架只负责上面的注解解析和注册逻辑。5.2 循环依赖和重复注册报错怎么解循环依赖的报错栈通常会非常长核心信息是UnsatisfiedDependencyException后面的注入链路比如OrderServiceImpl - MessageServiceImpl - OrderServiceImpl。这个链路一目了然直接按着链路去改模块依赖关系即可。如果链路太长可以先打印注册表里所有服务的依赖图在排查时特别有用。重复注册则是另一个高频问题。两个模块都提供了一个UserService注册表应该保留哪个我默认保留先注册的那个后注册的抛DuplicateServiceException直接让启动失败。很多团队怕启动失败所以改成后注册的覆盖先注册的结果出了问题很难排查——到底是哪个实现类在运行没人说得清。宁可启动时刺眼地失败也不要运行时的随机行为。5.3 配置文件里的参数怎么传给被注册的服务模块服务有时候需要读配置比如短信服务的签名、超时时间、开关。直接让服务实现类自己去读配置中心是可行的但更贴合框架的做法是在ServiceProvider注解上增加一个属性来声明配置前缀注册时由框架统一读取并注入。ServiceProvider(interfaces MessageService.class, configPrefix message.sms) public class MessageServiceImpl implements MessageService { private String signName; private long timeoutMs; public void configure(Properties config) { this.signName config.getProperty(signName); this.timeoutMs Long.parseLong(config.getProperty(timeoutMs, 3000)); } }框架在创建实例后如果检测到类实现了Configurable接口就会调用configure方法并传入解析出的配置节。这样做的好处是服务不直接依赖 Spring Environment 或某个具体配置框架将来切换配置中心时只有框架的解析部分需要改各路服务实现类不受影响。5.4 与其他框架的互操作以及命名冲突如果你在一个已经有 Spring、Guice 的老项目里接入这个框架最大的风险是命名冲突。比如 Spring 也有Service、Inject虽然注解类全限定名不同不会编译报错但代码审查时容易混淆。我的建议是给框架的注解起一个带业务色彩的名字比如ModuleService、ModuleInject尽量减少混用时的认知负担。6. 这套框架还能用在哪线程领域之外自动注册驱动的模块化思想其实到处都有。前端工程化里的微前端方案本质上就是一堆子应用模块自动注册到主应用基座AI 智能体里的 agent 框架把一个个工具、能力注册进编排引擎主程序负责调度测试框架里pytest自动发现测试用例也是通过约定和反射扫描实现的。所以不要觉得写了一套 Java 框架就只能用在这一个项目上。我在实际项目里已经把它迁移到了至少两种场景一种是 API 网关内的路由模块管理。每个协议适配器是一个模块新增一种对接协议时只要按规范打成 JAR 放进插件目录网关自动加载并注册路由处理器不用改网关主程序。这个场景下甚至是 JVM 之外的其他语言项目也能借鉴这套思路约定一个模块描述文件 一个入口类 一组服务接口语言和语言之间的实现差异不影响整体思路的通用性。另一种是批处理任务框架。每个批处理任务是一个模块框架通过ServiceProvider扫描自动注册任务实现然后由调度器按优先级和依赖关系去执行。任务之间不再通过静态方法互相调用全部走注册表排查问题变得特别干净。如果你想在自己的项目里落地我建议不要贪多求全。先拿一个非核心模块做试点把自动注册跑起来观察两个月确认稳定之后再逐步扩大范围。框架本身不该成为团队的负担它应该像工具一样用了觉得顺不用也能活。我个人在实际操作中最深的体会是很多人写框架失败不是代码写得不好而是想在一开始就做一个“万能框架”。我踩过这个坑第一次写的时候加了 AOP、事件总线、配置中心对接结果光调试那些扩展点就花了两周核心的注册功能反而没测透。第二次我砍掉所有非核心功能只留扫描、注册、注入三件事一周就上线了。先让主链路跑通再谈扩展这是我在这个框架上最大的收获。