模块化框架设计与自动服务注册:用Java打造轻量级依赖注入容器
2026/9/13 2:51:36 网站建设 项目流程

做后端这些年,“模块化”这三个字几乎每隔一阵就会被翻出来讨论一次。服务越拆越多、代码越来越乱,手动new对象、if-else 分发、复制粘贴注册逻辑,这些事我踩过太多次。今天聊的这套框架,核心只有两件事:模块化的代码组织,以及自动服务注册。它不算重,但解决了团队里真正棘手的几个问题——模块边界模糊、新增服务要改动注册代码、接口与实现强耦合。

如果你也被模块之间互相 import、改一个功能牵一发动全身、每加一个服务就要改一遍注册类这些事折磨过,这篇文章应该能给你一个轻量、可落地的答案。就算你不想从零写框架,把里面的扫描、注册、注入思路抄到项目里,也能明显改善代码结构。

1. 为什么还需要一个自己的模块化框架

1.1 从实际问题出发,看看“手动注册”有多痛

先说一个我经常遇到的场景:项目刚开始是单体应用,一个后台管理系统的代码全部塞在一个模块里,Controller、Service、Mapper 分层清晰,日子还算好过。但业务增长之后,来了支付、订单、用户、消息推送好几个大模块,项目开始拆 Maven 模块,问题立刻出来了。

模块 A 需要调用模块 B 的服务,于是 A 直接依赖 B 的实现类。今天 B 换了一个实现方案,改了个类名,A 的代码也要跟着改。更麻烦的是,模块之间的依赖关系没有收口,A 依赖 B,B 又依赖 C,C 居然反过来依赖 A,编译期看着没什么,运行起来偶发类加载问题,排查到凌晨也是常有的事。

手动注册的痛点就更具体了。早期我给项目写过一个ServiceRegistry,里面是密密麻麻的Map.put("orderService", new OrderService())。每新增一个服务,就要打开这个注册类,加一行,改一行,最后这个注册类膨胀到几百行,全是机械性的复制粘贴。一旦有人忘记注册,运行时直接空指针,报错信息还特别隐晦,指向业务代码而不是注册表。

这些问题的根源不是代码写得差,而是缺少一层“模块边界 + 自动装配”的基础设施。手动注册本质上是在维护一张服务清单,清单一旦和实际代码脱节,就是无穷无尽的运行时问题。

1.2 模块化框架与微服务注册中心的区别

很多人一听“自动服务注册”,第一反应是 Nacos、Eureka 那套微服务注册中心。这里必须把概念先理清,否则后面代码看不懂。

微服务注册中心解决的是跨进程的服务发现:服务 A 部署在机器 1,服务 B 部署在机器 2,A 怎么知道 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,提取接口类型、实现类类型、服务名等关键信息。

注册阶段,框架把解析出来的服务按“接口名 -> 实现类”的关系放进注册表。这个注册表本质上就是一个ConcurrentHashMap<String, 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会变成orderServiceImplinterfaces字段用来显式声明这个实现类要对外暴露哪些接口,不填的话默认取该类所有直接实现的接口。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 List<Class<?>> scan(String packageName) throws IOException, ClassNotFoundException { String path = packageName.replace('.', '/'); Enumeration<URL> resources = Thread.currentThread().getContextClassLoader().getResources(path); List<Class<?>> 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 Map<String, 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 注入了 ServiceB,ServiceB 又注入了 ServiceA,按上面的写法会死循环或栈溢出。我采用的方案是两阶段初始化:第一阶段先创建所有非单例且依赖复杂的对象的“空壳”实例(不注入字段),第二阶段统一注入。这样可以打破大多数循环依赖。如果你的场景出现了 A→B→C→A 的环,第一反应不该是怎么绕过去,而是重新审视模块划分——这种环说明模块边界没划清楚,拆开比硬解更有价值。

3.4 通过 SPI 机制让模块可以热扩展

到这里,框架已经能在启动时自动扫描并注册了,但还有个问题:如果新模块是后来打进 classpath 的,想要不修改主程序代码就能被加载,怎么办?这就需要 SPI(Service Provider Interface)机制。

我约定了一个目录:META-INF/modules/,每个模块 JAR 包在自己的 META-INF/modules 下放一个描述文件,文件名叫module.properties。主程序启动时,用类加载器去扫描 classpath 下所有这个目录里的文件,解析出模块名、入口类、依赖关系,然后动态加载。

public class ModuleLoader { public List<ModuleMeta> loadModules() throws IOException { Enumeration<URL> urls = Thread.currentThread() .getContextClassLoader() .getResources("META-INF/modules/module.properties"); List<ModuleMeta> 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,或者干脆用ClassPathScanningCandidateComponentProvider(Spring 自带的扫描器)来处理 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、事件总线、配置中心对接,结果光调试那些扩展点就花了两周,核心的注册功能反而没测透。第二次我砍掉所有非核心功能,只留扫描、注册、注入三件事,一周就上线了。先让主链路跑通,再谈扩展,这是我在这个框架上最大的收获。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询