【我的手搓轮子日记】(1)handmade-common 公共基础模块第一版

简介: 从零手搓 Java 框架的第一步:搭建公共基础模块,实现统一异常、字符串工具、包扫描器、反射工具和注解工具,为后续手搓 IOC、AOP、MVC 打好地基。

写在前面

说实话,一开始让我写 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 什么东西该放进去?判断标准

我总结了三条判断标准:

  1. 被两个以上模块需要 —— 只在一个模块用到的,就放那个模块里,别往 common 塞
  2. 不依赖 Servlet、JDBC、Netty 等具体技术 —— common 是纯 JDK 的,谁都能引,不能绑定特定技术
  3. 没有框架语义 —— @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 完整扫描流程

把上面的原理串起来,完整的包扫描流程是这样的:

  1. 把包名 com.flittly.ioc 转成路径 com/flittly/ioc(因为 ClassLoader 是按路径找资源的,不认识包名)
  2. ClassLoader.getResources("com/flittly/ioc") 找到这个路径对应的所有 URL
  3. 遍历每个 URL,判断协议是 file 还是 jar
  4. file 协议:用 File API 递归遍历目录,找 .class 文件
  5. jar 协议:用 JarFile API 遍历 jar 包内的 entry,找 .class 文件
  6. 找到 .class 文件后,把路径转回类全限定名(com/flittly/ioc/UserService.class -> com.flittly.ioc.UserService
  7. 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> 而不是 ListIterator。这是因为 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 的类,然后创建这些类的对象,再把这些对象注入到需要它们的地方

但问题是:框架写的时候,根本不知道你后面会写什么 UserServiceOrderService。框架是通用的,不能把 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 行。setAccessibletry-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(条件) -- 断言条件为 false
  • assertEquals(期望值, 实际值) -- 断言两者相等,不等就报错

比如 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),框架帮你遍历
过滤能力 FieldFilterMethodFilter,可以按修饰符、名称、返回值过滤
异常处理 统一抛 RuntimeException 区分了 IllegalStateExceptionIllegalArgumentException

8.3 HandmadeException 的差距

对比项 我们的实现 Spring 的实现
异常层次 只有一个 HandmadeException,所有模块共用 每个模块有专属异常(BeanExceptionBeanCreationExceptionBeanCurrentlyInCreationException 等),形成树状继承结构
错误信息 只有 message 和 cause errorCoderesourceDescription 等额外字段,能精确定位是哪个 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 个方法。如果后面发现还需要别的方法(比如 splitjoinreplace),再加就行,不用一开始就写 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 如果想继续深入

  1. 学 ASM:了解 .class 文件结构(魔数、常量池、访问标志),用 ASM 读类名和注解,不用加载类
  2. 看 Spring 源码ClassPathScanningCandidateComponentProvider 是 Spring 包扫描的入口,SimpleMetadataReader 是 ASM 读取的封装
  3. 研究 Spring Boot fat jar:为什么 Spring Boot 打包后是一个 jar,里面的类也能被扫到(LaunchedURLClassLoader

九、总结

9.1 几个要点

  1. common 是纯 JDK 的,不依赖任何第三方库(除了 lombok 和测试用的 junit)
  2. ClassScanner 是核心,IOC、MVC、AOP 都要用它扫包
  3. ReflectionUtils 是基石,IOC 的实例化、注入全靠它
  4. 不要过度设计,先写最小可用版本,后面需要什么再加
  5. 这是第一版,后续写其他模块时会回来迭代,逐步向工业级靠拢

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第二版,把这次发现的差距补上一部分。

目录
相关文章
|
3月前
|
Java 中间件 API
【AgentScope Java新手村系列】(10)实战-多Agent天气助手
实战-多Agent天气助手 — 文件驱动 subagent 实战,主 agent 自主编排天气查询、航班搜索、景点推荐三个子任务并行调研。
676 1
|
3月前
|
自然语言处理 Java API
【AgentScope Java新手村系列】(7)子Agent编排
子Agent编排 — SubagentDeclaration 描述子 agent,主 agent 通过 agent_spawn 工具同步/异步委派子任务。
685 0
|
3月前
|
前端开发 安全 中间件
【AgentScope Java新手村系列】(14)人机交互
人机交互 — Permission 系统五种模式配合 ALLOW/DENY/ASK 规则,运行时 HITL 自动拦截与决策收集。
469 6
【AgentScope Java新手村系列】(14)人机交互
|
2月前
|
Java 关系型数据库 MySQL
【AgentScope Java新手村系列】(18)Skills技能系统
用 SKILL.md 文件定义可复用技能,HarnessAgent 自动扫描匹配,智能体按需调用。
385 0
|
前端开发 Java 中间件
【AgentScope Java新手村系列】(1)框架简介与环境搭建
本章带你快速入门AgentScope Java 2.0:从GitHub拉取v2.0.0-RC2源码、Maven编译安装,到纯Java构建HarnessAgent,接入DeepSeek等主流LLM,跑通首个可对话智能体——完成学习之旅的“第0步”。
233 0
【AgentScope Java新手村系列】(1)框架简介与环境搭建
|
前端开发 Java 中间件
【AgentScope Java新手村系列】(3)工具系统
工具系统 — @Tool/@ToolParam 注解将 Java 方法注册为 Agent 能力,自主决定调用时机,支持同步/异步返回。
507 0
|
前端开发 NoSQL Java
【AgentScope Java新手村系列】(2)第一个Agent-基础对话
第一个Agent-基础对话 — 演示 HarnessAgent 的 Builder 模式创建、ReAct 推理循环、流式事件与思考模式三个核心能力。
836 2
|
3月前
|
SQL JSON Java
【AgentScope Java新手村系列】(15)MCP协议工具
MCP协议工具 — tools.json 一行声明一个 MCP server,支持 stdio/sse/ws 协议,McpMeta 传递调用元数据。
481 1
|
3月前
|
前端开发 NoSQL Java
【AgentScope Java新手村系列】(9)SpringBoot集成
SpringBoot集成 — 工厂方法将 HarnessAgent 注册为单例 Bean,WebFlux 流式输出 streamEvents 到 SSE 端点。
528 3
|
JSON 自然语言处理 Java
【AgentScope Java新手村系列】(4)结构化输出
结构化输出 — JSON Schema 约束 LLM 输出格式,直接反序列化为 Java POJO,打通文本到对象的转换。
525 0