写在前面
说实话,一开始让我写 common 模块我是懵的。啥都还没写呢,我怎么知道 common 里要放什么?后来踩了一圈坑才想明白:common 不是"设计"出来的,是"长"出来的。先把架子搭好,写其他模块的过程中发现"这个工具好像哪里都要用",再往 common 里挪。
这是 common 的第一版,只放了最基础的五个工具类。后面随着 IOC(控制反转)、AOP(面向切面编程)、MVC 等模块的推进,一定会发现新的通用需求,到时候会回来迭代 common第二版甚至是第三版。所以这篇与其说是教程,不如说是我把第一版的踩坑记录下来,给后续迭代打基础。
一、handmade-common 是什么?为什么需要它?
1.1 它是干什么的
handmade-common 是一个工具箱模块。它本身不实现任何框架功能(不是 IOC、不是 MVC、不是 AOP),而是提供一些所有模块都要用到的底层工具类,比如反射工具、包扫描工具、字符串工具。
打个比方:你要盖一栋楼(IOC)、两栋楼(MVC)、三栋楼(AOP),每栋楼都需要砖头、水泥、钢筋。common 就是那个生产砖头、水泥、钢筋的工厂。你不单独为每栋楼建一个工厂,而是集中生产、统一供应。
1.2 为什么需要它
写 IOC 的时候要用反射创建对象,写 AOP 的时候也要用反射调用方法,写 MVC 的时候还要用反射执行 Controller。如果每个模块各自写一份反射工具,代码就重复了。重复代码的后果是:改一处要改三处,漏改一处就出 bug。
common 就是把这些重复的、通用的工具集中到一个地方,所有模块依赖它就行。
1.3 什么东西该放进去?判断标准
我总结了三条判断标准:
- 被两个以上模块需要 —— 只在一个模块用到的,就放那个模块里,别往 common 塞
- 不依赖 Servlet、JDBC、Netty 等具体技术 —— common 是纯 JDK 的,谁都能引,不能绑定特定技术
- 没有框架语义 ——
@Component是 IOC 的注解,@Controller是 MVC 的注解,都不能放 common
基于这三条标准,common 里最终放了这些东西:
| 类 | 作用 | 谁会用到 |
|---|---|---|
HandmadeException |
统一异常基类 | 所有模块 |
StringUtils |
字符串工具 | IOC、ORM、MVC |
ClassScanner |
包扫描 | IOC、MVC、AOP |
ReflectionUtils |
反射工具 | IOC、AOP |
AnnotationUtils |
注解工具 | IOC、AOP |
下面一个一个来说。
二、HandmadeException —— 统一异常基类
2.1 这是什么?为什么需要它
HandmadeException 是所有手搓模块的异常父类。后续模块的异常(比如 IOC 的 BeanException、ORM 的 SqlException)都继承它。
为什么需要统一异常基类?因为后面写 MVC 的时候要做全局异常处理。如果 IOC 抛 RuntimeException,ORM 抛 SQLException,Security 抛 IllegalAccessException,你在 Controller 里总不能 catch 八百个异常吧?有了统一基类,MVC 层只需要 catch (HandmadeException) 就能兜底所有手搓模块的异常。
2.2 代码实现
package com.flittly.handmade.common.exception;
/**
* 所有手搓模块的统一异常基类
* 后续模块的异常(如 BeanException、SqlException)都继承这个类
*/
public class HandmadeException extends RuntimeException {
public HandmadeException() {
super();
}
public HandmadeException(String message) {
super(message);
}
public HandmadeException(Throwable cause) {
super(cause);
}
public HandmadeException(String message, Throwable cause) {
super(message, cause);
}
}
四个构造函数对应四种使用场景:
| 构造函数 | 什么时候用 | 举例 |
|---|---|---|
HandmadeException() |
不需要任何信息,只表示"出错了" | throw new HandmadeException() |
HandmadeException(String message) |
只需要描述问题 | throw new HandmadeException("找不到 Bean") |
HandmadeException(Throwable cause) |
捕获了别的异常,直接包装往上抛 | catch (SQLException e) { throw new HandmadeException(e) } |
HandmadeException(String message, Throwable cause) |
既描述问题,又保留原始异常 | catch (SQLException e) { throw new HandmadeException("查询失败", e) } |
第四个构造函数最常用,如果你捕获了一个底层异常(比如 SQLException),想往上抛但又不想丢失原始异常信息,就把原始异常作为 cause 传进去(Throwable 是 Java 所有异常和错误的根类)。调用方可以通过 exception.getCause() 拿到原始异常。
为什么不继承 Exception 而继承 RuntimeException?因为 Exception 是受检异常(编译器强制要求你要么 try-catch,要么声明 throws,否则编译不通过),每个方法都要声明 throws,太烦了,而 RuntimeException 是非受检异常。Spring 的 BeansException 也是继承 RuntimeException 的。
2.3 这个类什么都没加,怎么实现区分的
HandmadeException 里面没有任何额外字段和逻辑,全都是 super(...),看起来像白写了一层。它的区分能力不来自"添加了什么",而来自 Java 对象自带的类型信息。
每个 Java 对象在运行时都知道自己是什么类型。new HandmadeException("error") 创建的对象,虽然功能和 new RuntimeException("error") 一模一样,但它的类型标签是 HandmadeException。Java 的 catch 机制就是靠检查这个类型标签来匹配的:
throw new HandmadeException("找不到 Bean");
// catch 从上到下匹配,先命中的先执行
try {
...
} catch (HandmadeException e) {
// 命中!对象的实际类型是 HandmadeException
} catch (RuntimeException e) {
// 到不了这里,上面已经拦截了
}
打印异常时也能看出区别,类名会显示在堆栈第一行:
com.flittly.handmade.common.exception.HandmadeException: 找不到 Bean
at com.flittly.ioc.context.ApplicationContext.getBean(...)
所以区分靠两件事:catch 时的类型匹配,以及日志里的类名。不需要额外写字段或方法。
三、StringUtils —— 字符串工具
3.1 这是什么?为什么需要它
StringUtils 提供一些 Java 原生 String 没有的字符串操作方法。
为什么需要它?因为在手搓过程中,有好几个场景需要字符串转换:
- 写 IOC 扫包时,需要把包名
com.flittly.ioc转成路径com/flittly/ioc(因为 ClassLoader 是按路径找资源的) - 写 ORM 映射结果集时,需要把数据库列名
user_name转成 Java 字段名userName(驼峰命名) - 写 MVC 拼接 URL 时,需要处理首字母大小写
这些操作到处都要用,Java 原生 String 没有这些方法,Apache Commons 又太重了,不如自己写。
3.2 代码实现
package com.flittly.handmade.common.utils;
/**
* 字符串工具类
* 提供包名转路径、驼峰转换等常用操作
*/
public class StringUtils {
/**
* 判断字符串是否为空(null 或长度为 0)
*/
public static boolean isEmpty(String str) {
return str == null || str.isEmpty();
}
/**
* 判断字符串是否非空
*/
public static boolean isNotEmpty(String str) {
return !isEmpty(str);
}
/**
* 包名转路径:com.flittly.ioc -> com/flittly/ioc
* 因为 ClassLoader.getResources() 接收的是路径不是包名
*/
public static String packageToPath(String packageName) {
return packageName.replace('.', '/');
}
/**
* 下划线转驼峰:user_name -> userName
* 数据库列名转 Java 字段名时用
*
* 实现思路:遍历字符串,遇到下划线就跳过,把下一个字符大写
*/
public static String toCamelCase(String str) {
if (isEmpty(str)) {
return str;
}
StringBuilder sb = new StringBuilder();
boolean nextUpper = false;
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (c == '_') {
nextUpper = true;
} else {
if (nextUpper) {
sb.append(Character.toUpperCase(c));
nextUpper = false;
} else {
sb.append(c);
}
}
}
return sb.toString();
}
/**
* 首字母小写:UserService -> userService
* IOC 生成 Bean 名称时用
*/
public static String toLowerFirstCase(String str) {
if (isEmpty(str)) {
return str;
}
char firstChar = str.charAt(0);
if (Character.isUpperCase(firstChar)) {
char[] chars = str.toCharArray();
chars[0] = Character.toLowerCase(firstChar);
return new String(chars);
}
return str;
}
}
有个坑要注意:toCamelCase 一开始我用了 String.split("_") 然后拼接,结果遇到 user__name(连续两个下划线)就出 bug 了。后来改成逐字符遍历,稳多了。
四、ClassScanner —— 包扫描(common 里最难的部分)
4.1 这是什么?为什么需要它
ClassScanner 是一个包扫描器。给它一个包名(比如 com.flittly.ioc),它返回这个包下所有的 Class 对象。
为什么需要它?因为 IOC 要扫包找 @Component,MVC 要扫包找 @Controller,AOP 要扫包找 @Aspect。没有包扫描,你根本不知道哪些类需要被框架管理。这个功能太重要了,没有它什么都干不了。
4.2 包扫描要解决的问题
IOC 启动时需要自动发现带有 @Component、@Service、@Controller 等注解的类。但你的项目结构可能是这样的:
项目/
├── target/classes/ ← 编译后的 class 文件在这里
│ └── com/
│ └── example/
│ ├── UserService.class ← 要扫描
│ └── OrderController.class ← 要扫描
│
├── lib/
│ └── some-library.jar ← jar 包里也可能有要扫描的类
│ └── com/
│ └── thirdparty/
│ └── ThirdService.class
问题在于:这些类散落在文件系统目录和各种 jar 包里,怎么统一找到它们?
Java 没有提供"列出某个包下所有类"的 API(我们扫的是 .class 文件,因为 JVM 只认识 .class 文件)。但 ClassLoader 提供了 getResources() 方法,可以根据路径找到资源。关键思路就是利用这个方法。
4.3 核心原理:ClassLoader 怎么找到类文件
包扫描的第一步,也是最重要的一步,就是下面这段代码:
Enumeration<URL> resources = Thread.currentThread()
.getContextClassLoader() // 第一步
.getResources(path); // 第二步
看起来就两行,但每一行都有讲究。逐个拆解。
4.3.1 getContextClassLoader() -- 为什么要用上下文类加载器
ClassLoader cl = Thread.currentThread().getContextClassLoader();
这行代码获取的是当前线程绑定的类加载器(类加载器就是负责把 .class 文件加载到 JVM 内存里的工具),通常是 AppClassLoader。
Java 有三种类加载器,各自负责加载不同范围的类:
| 类加载器 | 加载范围 | 包扫描关心吗? |
|---|---|---|
| Bootstrap ClassLoader | JDK 核心类(java.lang.*) |
不需要,不扫 JDK 自己的类 |
| Ext ClassLoader | JDK 扩展目录 | 不需要 |
| App ClassLoader | 你的项目代码 + 所有 Maven/Gradle 依赖 | 正是要扫的 |
AppClassLoader 知道你的 classpath 在哪里,包括:
target/classes/(编译后的类文件目录)- 所有引入的 jar 包路径(
~/.m2/repository/下的依赖)
所以用 AppClassLoader 来找资源,就能覆盖项目里所有的类。
4.3.2 getResources(path) -- 它去哪里找
// 在 AppClassLoader 管理的所有 classpath 位置中
// 查找匹配 path 的资源,返回所有匹配项
Enumeration<URL> resources = cl.getResources("com/example");
这个方法会去 classpath 上的每一个位置找名为 com/example 的资源,把所有匹配的都返回。返回的结果是 URL 列表,可能有两种协议:
| 位置类型 | URL 示例 | 含义 |
|---|---|---|
| 文件系统目录 | file:/项目/target/classes/com/example/ |
开发时,类编译在 target 目录下 |
| jar 包内部 | jar:file:/项目/lib/some.jar!/com/example/ |
打包后,类在 jar 包里 |
这就解释了为什么后面要判断 url.getProtocol(),这是因为文件系统和 jar 包的遍历方式完全不同。
4.3.3 具体例子
假设你的 classpath 有:
target/classes/ ← AppClassLoader 知道
├── com/example/
│ ├── UserService.class
│ └── OrderController.class
~/.m2/repository/xxx/some-library.jar ← AppClassLoader 也知道
├── com/example/
│ └── ThirdService.class
执行 getResources("com/example") 后,返回两个 URL:
file:/项目/target/classes/com/example/ ← 本地目录
jar:file:/~/.m2/repository/xxx/some-library.jar!/com/example/ ← jar 包内部
拿到这两个 URL 后,分别用不同的方式遍历它们,就能找到所有的 .class 文件。
4.4 完整扫描流程
把上面的原理串起来,完整的包扫描流程是这样的:
- 把包名
com.flittly.ioc转成路径com/flittly/ioc(因为 ClassLoader 是按路径找资源的,不认识包名) - 用
ClassLoader.getResources("com/flittly/ioc")找到这个路径对应的所有 URL - 遍历每个 URL,判断协议是
file还是jar file协议:用FileAPI 递归遍历目录,找.class文件jar协议:用JarFileAPI 遍历 jar 包内的 entry,找.class文件- 找到
.class文件后,把路径转回类全限定名(com/flittly/ioc/UserService.class->com.flittly.ioc.UserService) - 用
Class.forName()加载这个类
传入包名 com.flittly.ioc
|
v
包名转路径: com.flittly.ioc -> com/flittly/ioc
|
v
ClassLoader.getResources("com/flittly/ioc")
(去 classpath 的所有位置找这个路径)
|
v
拿到 URL 列表(可能多个)
|
+---+---+
| |
file: jar:
| |
File API JarFile API
递归遍历 遍历 entry
目录 找 .class
找 .class
| |
+---+---+
|
v
路径转回类名: com/flittly/ioc/UserService.class
-> com.flittly.ioc.UserService
|
v
Class.forName() 加载
|
v
返回 List<Class<?>>
4.5 为什么用 Enumeration(中文译:枚举)
你可能注意到 getResources() 返回的是 Enumeration<URL> 而不是 List 或 Iterator。这是因为 ClassLoader.getResources() 这个方法在 1996 年就定好了,当时 Java 集合框架还不成熟,所以返回了 Enumeration。
Sun 后来没有改返回类型,因为改了会破坏所有依赖这个方法的代码。这是 JDK 的历史遗留设计。
现代代码可以这样处理,让它更易读:
// 转成 List,用增强 for 循环
List<URL> urlList = Collections.list(resources);
for (URL url : urlList) {
...
}
Collections.list(enumeration) 就是专门干这个的,把 Enumeration 转成 ArrayList。
4.6 代码实现
package com.flittly.handmade.common.scanner;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
/**
* 包扫描器
* 给一个包名,返回这个包下所有的 Class 对象
*
* 支持 file 协议(开发时)和 jar 协议(打包后)
*/
public class ClassScanner {
/**
* 扫描指定包下的所有类
*
* @param basePackage 基础包名,如 "com.flittly.ioc"
* @return 该包下所有类的 Class 对象列表
*/
public static List<Class<?>> scan(String basePackage) throws IOException, ClassNotFoundException {
List<Class<?>> classes = new ArrayList<>();
String path = basePackage.replace('.', '/');
// 用 ClassLoader 找到这个包对应的所有 URL
// 可能是 file:(开发时在 target/classes 下),也可能是 jar:(打包后)
Enumeration<URL> resources = Thread.currentThread() // 获取当前正在执行的线程
.getContextClassLoader() // 获取该线程的上下文类加载器
.getResources(path); // 从calsspath中查找所有匹配path的资源
while (resources.hasMoreElements()) {
URL url = resources.nextElement();
String protocol = url.getProtocol(); // 获取 URL 的协议类型
if ("file".equals(protocol)) {
// 开发环境:URL 转 File,递归遍历目录
File dir = new File(url.getFile());
scanFile(dir, basePackage, classes);
} else if ("jar".equals(protocol)) {
// 打包环境:遍历 jar 包内的 entry
scanJar(url, basePackage, classes);
}
}
return classes;
}
/**
* file 协议扫描:递归遍历目录,找 .class 文件
*
* @param dir 要扫描的目录
* @param basePackage 当前目录对应的包名(递归时会拼接子目录名)
*/
private static void scanFile(File dir, String basePackage, List<Class<?>> classes)
throws ClassNotFoundException {
if (!dir.exists() || !dir.isDirectory()) {
return;
}
File[] files = dir.listFiles();
if (files == null) {
return;
}
for (File file : files) {
if (file.isDirectory()) {
// 子目录:包名加上子目录名,递归
scanFile(file, basePackage + "." + file.getName(), classes);
} else if (file.getName().endsWith(".class")) {
// .class 文件:路径转类名
String className = basePackage + "." +
file.getName().substring(0, file.getName().length() - 6);
try {
classes.add(Class.forName(className));
} catch (NoClassDefFoundError e) {
// 有些类在运行时依赖不完整,加载会失败,跳过即可
}
}
}
}
/**
* jar 协议扫描:遍历 jar 包内的 entry
*/
private static void scanJar(URL url, String basePackage, List<Class<?>> classes)
throws ClassNotFoundException, IOException {
// jar URL 格式:jar:file:/xxx.jar!/com/flittly/ioc/
String jarPath = url.getPath().substring(5, url.getPath().indexOf("!")); //结果就是:/xxx.jar
try (JarFile jar = new JarFile(jarPath)) {
Enumeration<JarEntry> entries = jar.entries();
String packagePath = basePackage.replace('.', '/');
while (entries.hasMoreElements()) {
JarEntry entry = entries.nextElement();
String name = entry.getName();
if (name.startsWith(packagePath) && name.endsWith(".class")) {
String className = name.substring(0, name.length() - 6).replace('/', '.');
try {
classes.add(Class.forName(className));
} catch (NoClassDefFoundError e) {
// 跳过无法加载的类
}
}
}
}
}
}
踩坑记录:
第一个坑是 Class.forName() 抛 ClassNotFoundException。原因是扫到了一些内部类(UserService$InnerClass.class),类名里有 $,Class.forName 不认识。后来加了 try-catch 跳过。
第二个坑是 jar 包扫描。开发时跑得好好的,一打包就扫不到类了。debug 了半天发现 url.getPath() 的格式是 jar:file:/xxx.jar!/com/flittly/,要截取 file: 后面、! 前面的部分才是 jar 文件路径。
五、ReflectionUtils —— 反射工具
5.1 为什么需要反射
先想一个问题:IOC 容器要做的就是扫描包,找到标了 @Component 的类,然后创建这些类的对象,再把这些对象注入到需要它们的地方。
但问题是:框架写的时候,根本不知道你后面会写什么 UserService、OrderService。框架是通用的,不能把 new UserService() 写死在代码里。那框架怎么创建一个它不知道名字的对象?
答案就是反射。反射允许程序在运行时动态地知道类名、创建对象、读写字段。IOC 容器正是靠反射来实现的:
- 扫包拿到类名后,用反射创建对象:
clazz.newInstance() - 找到
@Autowired字段后,用反射往字段里塞值:field.set() - AOP 要代理方法时,用反射调用方法:
method.invoke()
没有反射,IOC 和 AOP 都做不了。所以我们需要一个反射工具类,把这些反射操作封装起来,供后续所有模块使用。
5.2 ReflectionUtils 是干什么的
Java 原生反射 API 已经提供了 newInstance()、field.set()、field.get() 这些方法,我们并没有创造新能力。ReflectionUtils 的唯一目的是把 JDK 反射 API 包一层,减少调用方的重复代码--如果不封装,每次反射调用都要写 setAccessible + try-catch,20 个地方用就重复 20 遍,封装后调用方只需一行。
5.3 不封装会怎样
IOC 容器做依赖注入时,要遍历 Bean 的字段,找到 @Autowired 的往里塞值。如果不封装,代码长这样:
// 不封装,直接用 JDK 反射
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(Autowired.class)) {
Object dependency = beanMap.get(field.getType());
field.setAccessible(true); // 每次都要写
try {
field.set(bean, dependency); // 每次都要 try-catch
} catch (IllegalAccessException e) {
throw new RuntimeException(e); // 每次都要包异常
}
}
}
封装后:
// 封装后,用 ReflectionUtils
for (Field field : clazz.getDeclaredFields()) {
if (field.isAnnotationPresent(Autowired.class)) {
Object dependency = beanMap.get(field.getType());
ReflectionUtils.setField(field, bean, dependency); // 一行搞定
}
}
同样的逻辑,封装前 9 行,封装后 4 行。setAccessible、try-catch、异常包装全藏在 setField 里面了,调用方只关心"把 dependency 塞进 field"。
这段代码后面会在第二篇 IOC 的 ApplicationContext.injectDependencies 方法里原样出现。
5.4 代码实现
package com.flittly.handmade.common.utils;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
/**
* 反射工具类
* 提供实例化、字段操作等常用反射操作
* 注解相关操作放在 AnnotationUtils 里
*/
public class ReflectionUtils {
/**
* 反射创建实例(调用无参构造)
*/
public static Object newInstance(Class<?> clazz) {
try {
return clazz.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("无法实例化 " + clazz.getName() +
",可能没有无参构造", e);
}
}
/**
* 获取类上声明的所有字段(包括 private,不包括父类)
*/
public static List<Field> getDeclaredFields(Class<?> clazz) {
List<Field> fields = new ArrayList<>();
Field[] declared = clazz.getDeclaredFields();
for (Field field : declared) {
fields.add(field);
}
return fields;
}
/**
* 获取类上所有字段(包括父类)
* IOC 如果要支持父类字段注入,需要用这个
*/
public static List<Field> getAllFields(Class<?> clazz) {
List<Field> fields = new ArrayList<>();
Class<?> current = clazz;
while (current != null && current != Object.class) {
for (Field field : current.getDeclaredFields()) {
fields.add(field);
}
current = current.getSuperclass();
}
return fields;
}
/**
* 设置字段值(突破private限制)
*/
public static void setField(Field field, Object target, Object value) {
try {
field.setAccessible(true); // 跳过 private 访问检查
field.set(target, value); // 等价于 target.field = value
} catch (IllegalAccessException e) {
throw new RuntimeException("无法设置字段 " + field.getName(), e);
}
}
/**
* 获取字段值(强制可访问)
*/
public static Object getFieldValue(Field field, Object target) {
try {
field.setAccessible(true);
return field.get(target);
} catch (IllegalAccessException e) {
throw new RuntimeException("无法读取字段 " + field.getName(), e);
}
}
}
5.5 setAccessible(true) 是怎么突破 private 的
IOC 做依赖注入时,场景是这样的:
@Component
public class UserServiceImpl implements UserService {
@Autowired
private UserDao userDao; // private!
}
容器创建了 UserServiceImpl 实例后,要把 userDao 字段的值塞进去。但 userDao 是 private 的,外部直接访问会报错。反射的 field.set() 默认也会检查访问权限,不先 setAccessible(true) 就会抛 IllegalAccessException。
setAccessible(true) 的作用是告诉 JVM 跳过访问权限检查,相当于拿到一把万能钥匙,private 字段也能读写。这不是绕过安全管理器(SecurityManager),而是跳过 Java 语言层面的访问控制检查。
一开始我忘了这行,结果注入 private 字段时直接 IllegalAccessException。反射操作 private 成员,必须先 setAccessible(true)。
5.6 getDeclaredFields 和 getAllFields 的区别
getDeclaredFields() 只返回这个类自己声明的字段,不包括父类的。如果 BaseService 有个 @Autowired 字段,子类继承后用 getDeclaredFields() 扫不到它,注入会漏掉。
getAllFields() 解决了这个问题:从当前类开始,沿着继承链一直往上找(current = current.getSuperclass()),把每一层的字段都收集起来,直到 Object 为止。
六、AnnotationUtils —— 注解工具
6.1 这是什么?为什么需要它
AnnotationUtils 提供注解查找的封装方法,比如"判断某个类上有没有 @Component 注解"。
为什么需要它?IOC 要判断类上有没有 @Component,AOP 要判断类上有没有 @Aspect,MVC 要判断类上有没有 @Controller。这些操作都是"判断某个元素上有没有某个注解",封装起来更简洁。
6.2 为什么单独搞一个类,不放 ReflectionUtils 里
因为反射和注解是两个不同的关注点。ReflectionUtils 专注反射操作(创建对象、读写字段),AnnotationUtils 专注注解操作(判断注解、获取注解)。分开后职责清晰,需要注解功能就引 AnnotationUtils,需要反射功能就引 ReflectionUtils,不会把不相关的方法也带进来。
因为后面写 AOP 的时候,需要处理"元注解"——也就是注解上的注解。比如 @Component 上标了 @Target 和 @Retention,如果你想在 @Component 上再标一个自定义注解,然后判断一个类有没有被这个"组合注解"标记,就需要递归查找。这个功能 isAnnotationPresent 做不到。初版先简化,只做基础功能,把位置占住。
6.3 代码实现
package com.flittly.handmade.common.utils;
import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
/**
* 注解工具类
* 提供注解查找功能,初版只支持直接标注的注解
* 后续 AOP 模块需要时可扩展元注解查找
*/
public class AnnotationUtils {
/**
* 判断元素上是否有指定注解
*/
public static boolean hasAnnotation(AnnotatedElement element,
Class<? extends Annotation> annotationType) {
return element != null && element.isAnnotationPresent(annotationType);
}
/**
* 获取元素上的注解
*/
public static <A extends Annotation> A getAnnotation(AnnotatedElement element,
Class<A> annotationType) {
if (element == null) {
return null;
}
return element.getAnnotation(annotationType);
}
}
七、验证清单
写完 common 后,需要写测试类来验证每个工具方法是否正常工作。
7.1 测试类放在哪
测试类放在 handmade-common/src/test/java/com/flittly/handmade/common/HandmadeCommonTest.java。
Maven 规定:main/java 下写正式代码,test/java 下写测试代码。测试类的包名要和被测试的类一致(com.flittly.handmade.common),这样能直接访问同包下的类。
7.2 测试代码
package com.flittly.handmade.common;
import com.flittly.handmade.common.exception.HandmadeException;
import com.flittly.handmade.common.scanner.ClassScanner;
import com.flittly.handmade.common.utils.StringUtils;
import com.flittly.handmade.common.utils.ReflectionUtils;
import com.flittly.handmade.common.utils.AnnotationUtils;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class HandmadeCommonTest {
/**
* 测试 StringUtils 的每个方法是否返回预期结果
*/
@Test
void testStringUtils() {
// isEmpty:null 和空字符串应该返回 true
assertTrue(StringUtils.isEmpty(null));
assertTrue(StringUtils.isEmpty(""));
// isNotEmpty:非空字符串应该返回 true
assertTrue(StringUtils.isNotEmpty("abc"));
// packageToPath:点号应该变成斜杠
assertEquals("com/flittly/ioc", StringUtils.packageToPath("com.flittly.ioc"));
// toCamelCase:下划线转驼峰
assertEquals("userName", StringUtils.toCamelCase("user_name"));
// toLowerFirstCase:首字母小写
assertEquals("userService", StringUtils.toLowerFirstCase("UserService"));
}
/**
* 测试 ClassScanner 能否扫到 common 包下的类
*/
@Test
void testClassScanner() throws Exception {
// 扫描 common 模块自己的包
List<Class<?>> classes = ClassScanner.scan("com.flittly.handmade.common");
// 扫到的列表不应该为空
assertFalse(classes.isEmpty());
// 应该能找到 StringUtils 这个类
// 遍历扫描结果,看有没有类名包含 "StringUtils" 的
// .anyMatch()` 自己从流里逐个取出元素来判断
assertTrue(classes.stream().anyMatch(c -> c.getSimpleName().contains("StringUtils")));
}
/**
* 测试 ReflectionUtils 能否反射创建对象
*/
@Test
void testReflectionUtils() {
// 用反射创建 StringUtils 的实例
Object instance = ReflectionUtils.newInstance(StringUtils.class);
// 创建出来的实例不应该是 null
assertNotNull(instance);
// 应该是 StringUtils 类型
assertTrue(instance instanceof StringUtils);
}
/**
* 测试 HandmadeException 的四个构造函数
*/
@Test
void testHandmadeException() {
// 无参构造
HandmadeException ex0 = new HandmadeException();
assertNotNull(ex0);
// 只传消息
HandmadeException ex1 = new HandmadeException("test");
assertEquals("test", ex1.getMessage());
// 只传 cause
RuntimeException cause = new RuntimeException("原始异常");
HandmadeException ex2 = new HandmadeException(cause);
assertSame(cause, ex2.getCause());
// 传消息 + cause
HandmadeException ex3 = new HandmadeException("test", cause);
assertEquals("test", ex3.getMessage());
assertSame(cause, ex3.getCause());
}
}
7.3 测试代码怎么看
每个 @Test 方法对应一个测试用例,核心就是"调用方法 -> 检查结果是否符合预期":
assertTrue(条件)-- 断言条件为 true,否则测试失败assertFalse(条件)-- 断言条件为 falseassertEquals(期望值, 实际值)-- 断言两者相等,不等就报错
比如 assertEquals("userName", StringUtils.toCamelCase("user_name")) 的意思是:调用 toCamelCase("user_name"),期望返回值是 "userName",如果不是,测试就会报红。
四个测试方法分别测了 common 的四个类:
| 测试方法 | 测什么 | 怎么验证 |
|---|---|---|
testStringUtils |
字符串工具 | 调每个方法,检查返回值 |
testClassScanner |
包扫描器 | 扫自己包,检查能否找到 StringUtils |
testReflectionUtils |
反射工具 | 反射创建 StringUtils 实例,检查不是 null |
testHandmadeException |
异常基类 | 创建异常对象,检查消息和 cause |
写完后跑 mvn test,全绿就算 common 模块搞定了。
八、和工业级框架的差距(后续迭代的方向)
我们的 common 第一版虽然能用,但和 Spring 的工具类比起来,差距还是很大的。把这些差距记录下来,作为后续迭代的路线图。在后续我们写这些模块的时候,每写完一个模块回来复盘,看看哪些差距可以进行弥补。
8.1 ClassScanner 的差距
| 对比项 | 我们的实现 | Spring 的实现 |
|---|---|---|
| 扫描方式 | 拿到 Class 对象(Class.forName) |
用 ASM 读字节码,不加载类 |
| 性能 | 每个类都要 Class.forName,会触发类初始化 |
只读 .class 文件头部信息,不触发类加载 |
| 过滤能力 | 只能判断有没有注解 | 支持排除过滤器、包含过滤器、正则匹配、 assignable 过滤 |
| 嵌套 jar | 不支持 | 支持 Spring Boot fat jar 里的嵌套 jar |
| 缓存 | 没有 | 扫描结果可缓存,避免重复扫描 |
最大的差距是 ASM。Spring 用 ASM(一个字节码操作库)直接读 .class 文件的内容,不需要调用 Class.forName() 把类加载到 JVM 里。这样做的好处是:扫描速度极快,而且不会因为扫描触发类的静态初始化块。我们的实现每扫一个类就 Class.forName 一次,如果某个类的静态块有副作用(比如连数据库),扫描时就会出问题。我们不用的它的原因是因为如果直接使用ASM,那我们对它原理的理解侧重就会偏移到理解字节码解析,这就属于喧宾夺主了。
8.2 ReflectionUtils 的差距
| 对比项 | 我们的实现 | Spring 的实现 |
|---|---|---|
| 反射缓存 | 没有缓存,每次反射都重新查 | 缓存了 Field/Method 元数据,避免重复反射查找 |
| API 设计 | 返回 List,调用方自己遍历 | 回调模式(ReflectionUtils.FieldCallback),框架帮你遍历 |
| 过滤能力 | 无 | FieldFilter、MethodFilter,可以按修饰符、名称、返回值过滤 |
| 异常处理 | 统一抛 RuntimeException | 区分了 IllegalStateException 和 IllegalArgumentException |
8.3 HandmadeException 的差距
| 对比项 | 我们的实现 | Spring 的实现 |
|---|---|---|
| 异常层次 | 只有一个 HandmadeException,所有模块共用 | 每个模块有专属异常(BeanException、BeanCreationException、BeanCurrentlyInCreationException 等),形成树状继承结构 |
| 错误信息 | 只有 message 和 cause | 有 errorCode、resourceDescription 等额外字段,能精确定位是哪个 Bean、哪个资源出了问题 |
| 国际化 | 不支持 | 支持 MessageSource,错误信息可以根据用户语言切换 |
我们的 HandmadeException 是个"万能异常",IOC 出错抛它,ORM 出错也抛它,调用方只知道"出错了",不知道具体是什么类型的错。Spring 的做法是每个模块有自己的异常子类,比如 NoSuchBeanDefinitionException(找不到 Bean)和 BeanCreationException(创建 Bean 失败)是两个不同的异常类,catch 时可以精确区分"是找不到还是创建失败"。
8.4 StringUtils 的差距
| 对比项 | 我们的实现 | Spring/Apache Commons 的实现 |
|---|---|---|
| 方法数量 | 5 个(isEmpty、isNotEmpty、packageToPath、toCamelCase、toLowerFirstCase) | Spring 有 30+ 个,Apache Commons 有 100+ 个 |
| 功能覆盖 | 基础判空、驼峰转换、包名转路径 | 拆分、拼接、替换、截取、首字母大小写、containsAny、substringBefore/After 等 |
| null 安全 | 只处理了 isEmpty 的 null | 所有方法都做了 null 安全处理,不会 NPE |
| 性能 | 每次操作都创建新字符串 | 大量使用 StringBuilder 和字符数组,减少对象创建 |
我们的 StringUtils 只写了手搓过程中确实用到的 5 个方法。如果后面发现还需要别的方法(比如 split、join、replace),再加就行,不用一开始就写 100 个。Spring 和 Apache Commons 的 StringUtils 是经过十几年积累的,不是一次写完的。
8.5 AnnotationUtils 的差距
| 对比项 | 我们的实现 | Spring 的实现 |
|---|---|---|
| 查找方式 | 只能找直接标注的注解 | 支持递归查找元注解(注解上的注解) |
| 组合注解 | 不支持 | AnnotatedElementUtils.findMergedAnnotation() 能找到组合注解并合并属性 |
| 重复注解 | 不支持 | 支持 @Repeatable 重复注解 |
| 注解属性覆盖 | 不支持 | 支持注解属性别名(@AliasFor),一个属性可以映射到另一个属性 |
最大的差距是元注解查找。比如 Spring 的 @Component 上没有直接标 @Service,但 @Service 上标了 @Component。Spring 能判断一个标了 @Service 的类也"间接"拥有 @Component 注解,因为它会递归查注解上的注解。我们的 isAnnotationPresent 只看直接标注的,标了 @Service 的类不会被识别为有 @Component。这个功能在写 AOP 的时候会用到,到时再扩展。
8.6 如果想继续深入
- 学 ASM:了解
.class文件结构(魔数、常量池、访问标志),用 ASM 读类名和注解,不用加载类 - 看 Spring 源码:
ClassPathScanningCandidateComponentProvider是 Spring 包扫描的入口,SimpleMetadataReader是 ASM 读取的封装 - 研究 Spring Boot fat jar:为什么 Spring Boot 打包后是一个 jar,里面的类也能被扫到(
LaunchedURLClassLoader)
九、总结
9.1 几个要点
- common 是纯 JDK 的,不依赖任何第三方库(除了 lombok 和测试用的 junit)
- ClassScanner 是核心,IOC、MVC、AOP 都要用它扫包
- ReflectionUtils 是基石,IOC 的实例化、注入全靠它
- 不要过度设计,先写最小可用版本,后面需要什么再加
- 这是第一版,后续写其他模块时会回来迭代,逐步向工业级靠拢
9.2 完整代码结构
handmade-common/
└── src/main/java/com/flittly/handmade/common/
├── exception/
│ └── HandmadeException.java
├── utils/
│ ├── StringUtils.java
│ ├── ReflectionUtils.java
│ └── AnnotationUtils.java
└── scanner/
└── ClassScanner.java
下一篇我们开始手搓 IOC 容器,那才是真正的硬仗。等 IOC 写完,我们可能会回来写 common第二版,把这次发现的差距补上一部分。