一、先别急着“干掉前端”,先把后端这层钢板焊好
很多开发者被“前后端分离”教育了太多年,一提到管理系统,脑子里第一反应就是“我要先起一个 Vue 项目,再配一个 React 脚手架,然后写一堆表格、表单、弹窗”。但说实话,大多数后台管理系统的需求高度相似:列表查询、新增、修改、删除、导出、权限控制。前端页面来来去去就是那几个模板,真正值得花时间的反而是接口稳定性、数据一致性和权限边界。
所以这篇文章我们反其道而行之:暂时把前端工程从你的工作流里“干掉”,用纯 Java 注解,在 3 分钟内搭起一个可以直接跑起来的后端管理系统骨架。这里的“干掉前端”不是真的不做界面,而是先用注解把后端能力全部暴露出来,让前端变成可有可无的薄壳。等你把注解驱动这套思路吃透,后面无论是接 Vue、React,还是用模板引擎直接渲染 HTML,都会轻松很多。
本文会从 Java 注解的基础开始,一直讲到利用反射、动态代理和少量 SPI 机制,手写一个极简但完整的 Web 框架:注解自动路由、参数注入、JSON 序列化、ORM 自动建表、通用 CRUD、权限拦截、参数校验、异步任务、配置读取。文章篇幅较长,建议收藏后跟着代码逐步实践。
二、注解的本质:从 @Override 到自定义注解
很多 Java 开发者每天都在用注解,但很少停下来想清楚:注解到底是什么?简单说,注解是一种元数据,它本身不包含业务逻辑,只是给类、方法、字段、参数等程序元素贴上一张“标签”。编译器、框架或运行时反射机制读取这张标签后,再决定要做什么。
我们最熟悉的@Override就是一个典型的编译期注解:它告诉编译器“这个方法应该重写父类方法”,如果拼错方法名,编译器会直接报错。但@Override的生命周期在编译期就结束了,运行时 class 文件里虽然可能保留信息,但业务程序通常不会在运行期再读它。而我们要做的管理系统,需要的是运行时注解,也就是程序跑起来之后,框架自己扫描并解释这些注解。
下面是一个最基本的自定义注解:
java
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface ManagedResource { String name(); String description() default ""; }这段代码里有两个元注解需要重点理解:@Target表示这个注解可以贴在什么元素上,ElementType.TYPE表示只能贴在类、接口或枚举上;@Retention(RetentionPolicy.RUNTIME)表示这个注解在运行时仍然可以被反射读取。没有 RUNTIME 的注解,运行期是拿不到的,也就无法用来驱动框架。
三、自己写注解:把业务意图声明出来
做管理系统时,我们最希望的是“声明式开发”。比如一个用户管理模块,我们希望写一个类、标几个注解,系统就自动知道:这个类对应哪个数据库表、哪个字段是主键、哪个字段不能被前端覆盖、哪个接口需要管理员权限。这样代码就从“实现细节”变成了“意图声明”。
我们可以先设计一组和 Web 请求相关的注解:
java
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface Controller { String value() default ""; } @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequestMapping { String value(); String method() default "GET"; } @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface RequestParam { String value(); boolean required() default true; String defaultValue() default ""; } @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface RequestBody { }这段代码的目的很纯粹:把一个普通的 Java 方法变成一个可以被 HTTP 调用的接口。类上标@Controller表示这是一个控制器;方法上标@RequestMapping表示这个方法对应一个 URL 路径;参数上标@RequestParam表示从查询参数或表单里取一个值,标@RequestBody表示从请求体里反序列化一个 JSON 对象。
有了这些注解,未来写一个接口就会变成这样:
java
@Controller("/user") public class UserController { @RequestMapping(value = "/list", method = "GET") public ApiResponse list(@RequestParam("page") int page, @RequestParam("size") int size) { return ApiResponse.ok(userService.page(page, size)); } }这几乎已经和我们熟悉的 Spring Boot 写法很像了。但我们要做的是理解框架底层如何把这些注解变成可执行逻辑,而不是直接依赖某个公司封装的框架。
四、元注解的组合:用注解去描述注解
当你写过几个注解之后会发现,很多注解都有相似的结构:必须定义@Retention(RUNTIME),经常要指定@Target,有时候还要加@Documented或@Inherited。为了减少重复,JDK 也支持把多个元注解组合到一个自定义注解里,但严格说,Java 语言层面并不支持“注解继承”。我们通常是用一个新的注解来统一携带这些元信息,然后让业务注解标注它,或者使用注解处理器来间接实现。
不过对于本文的管理系统,我们不需要把注解体系做得过分复杂。只要记住四个最常用的元注解即可:
@Target:限制注解可以使用的位置,常用值包括
TYPE、FIELD、METHOD、PARAMETER、CONSTRUCTOR等。@Retention:控制注解保留阶段,运行时框架必须使用
RUNTIME。@Documented:表示注解会出现在 javadoc 中,方便生成文档。
@Inherited:表示子类可以继承父类上的该注解,但仅对类注解有效。
理解这四者之后,后面不管看到 Spring、MyBatis 还是各种自定义框架的注解,你都能一眼看出它的生命周期和作用范围。这也是从“会用注解”走向“会设计注解”的关键一步。
五、运行时反射:让注解动起来
注解只是标签,真正让系统自动工作的是反射。反射允许程序在运行期间读取类结构、创建对象、调用方法、访问字段。我们的注解框架本质上就是:启动时扫描指定包下的所有类,找到带@Controller的类,再找到带@RequestMapping的方法,把 URL 和方法的映射关系缓存起来;请求进来时,根据 URL 查表,反射调用方法;通过参数注解从请求里解析参数;方法返回后把结果序列化成 JSON 写回响应。
下面是一段最基础的反射扫描代码,用来验证注解是否能被读取:
java
import java.lang.reflect.Method; public class AnnotationScanner { public static void main(String[] args) throws Exception { Class<?> clazz = UserController.class; if (clazz.isAnnotationPresent(Controller.class)) { Controller controller = clazz.getAnnotation(Controller.class); System.out.println("控制器:" + clazz.getSimpleName()); System.out.println("基础路径:" + controller.value()); } for (Method method : clazz.getDeclaredMethods()) { if (method.isAnnotationPresent(RequestMapping.class)) { RequestMapping mapping = method.getAnnotation(RequestMapping.class); System.out.println("接口:" + mapping.method() + " " + mapping.value()); } } } }运行后你会看到控制台打印出类和方法的注解信息。这就是整个框架的起点:只要我们在启动阶段把所有带注解的类收集起来,就能根据 URL 找到对应方法,并反射执行。
注意:反射调用比直接调用慢,因此在框架设计中,我们通常会在启动时做一次扫描和缓存,把
Method对象和参数元数据保存起来,请求处理时直接从缓存取,而不是每次都扫描。
六、搭一个极简 Web 容器:用内置 HttpServer 跑起来
很多人以为做 Web 服务一定要依赖 Tomcat、Jetty 这种外部容器。其实 JDK 内置的com.sun.net.httpserver.HttpServer已经足够我们演示和跑一些轻量级后台接口。借助它,我们可以在 3 分钟内启动一个可用的 HTTP 服务,完全不需要额外依赖,也不需要写 web.xml。
先写一个简单的服务启动类:
java
import com.sun.net.httpserver.HttpServer; import java.net.InetSocketAddress; public class MiniServer { public static void start(int port) throws Exception { HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); server.createContext("/", new MiniHttpHandler()); server.setExecutor(java.util.concurrent.Executors.newFixedThreadPool(20)); server.start(); System.out.println("服务已启动:http://127.0.0.1:" + port); } }HttpServer.createContext注册了一个根路径处理器,所有没有更精确上下文的请求都会进入MiniHttpHandler。在这个处理器里,我们读取请求方法、路径、查询参数和请求体,然后交给注解路由分发器处理。
这里之所以没有使用 Servlet,是为了减少概念负担。Servlet 虽然经典,但需要容器、初始化和生命周期概念,反而容易让初学者把注意力从“注解驱动”转移开。等这套机制跑通后,你完全可以把核心逻辑平移到 Spring、Javalin 或任何 Web 框架中。
七、包扫描器:把全项目的注解类一网打尽
要让框架自动发现控制器,就必须有一个包扫描器。所谓包扫描,就是根据一个基础包名,找到该包及子包下所有 class 文件,加载它们,并检查是否带有关心注解。这里我们可以利用类路径扫描:通过Thread.currentThread().getContextClassLoader()读取指定路径下的资源文件。
java
import java.io.File; import java.net.URL; import java.util.ArrayList; import java.util.List; public class ClasspathScanner { public static List<Class<?>> scan(String basePackage) throws Exception { String path = basePackage.replace('.', '/'); ClassLoader loader = Thread.currentThread().getContextClassLoader(); URL url = loader.getResource(path); if (url == null) { return new ArrayList<>(); } File dir = new File(url.toURI()); List<Class<?>> classes = new ArrayList<>(); for (File file : dir.listFiles()) { if (file.isDirectory()) { classes.addAll(scan(basePackage + "." + file.getName())); } else if (file.getName().endsWith(".class")) { String className = basePackage + "." + file.getName().substring(0, file.getName().length() - 6); classes.add(Class.forName(className)); } } return classes; } }这段扫描器适合普通项目和可执行 JAR 场景。如果项目运行在 Spring Boot 嵌套 JAR 或特殊类加载器环境下,直接遍历File会失效,需要改为扫描 JAR 条目。但我们的目标是把原理讲清楚,先跑通普通目录结构即可。
扫描完成后,我们可以过滤带有@Controller的类,并继续读取其中的方法注解。
八、路由注册:把 URL 和方法缓存进内存
包扫描结果拿到后,就需要建立路由表。路由表通常是一个Map<String, RouteDefinition>,其中 key 可以是“HTTP 方法 + 路径”,value 包含目标类的Class对象、目标Method对象、参数列表以及参数注解元数据。
java
import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; public class Router { private final Map<String, RouteDefinition> routes = new HashMap<>(); public void register(Class<?> controllerClass) { Controller controller = controllerClass.getAnnotation(Controller.class); String basePath = controller.value(); if (!basePath.startsWith("/")) { basePath = "/" + basePath; } for (Method method : controllerClass.getDeclaredMethods()) { if (!method.isAnnotationPresent(RequestMapping.class)) { continue; } RequestMapping mapping = method.getAnnotation(RequestMapping.class); String subPath = mapping.value(); if (!subPath.startsWith("/")) { subPath = "/" + subPath; } String fullPath = normalize(basePath + subPath); String key = mapping.method().toUpperCase() + " " + fullPath; RouteDefinition definition = new RouteDefinition(controllerClass, method); routes.put(key, definition); } } private String normalize(String path) { if (path.length() > 1 && path.endsWith("/")) { path = path.substring(0, path.length() - 1); } return path; } public RouteDefinition match(String method, String path) { String key = method.toUpperCase() + " " + path; if (routes.containsKey(key)) { return routes.get(key); } String fixedKey = method.toUpperCase() + " " + (path.endsWith("/") && path.length() > 1 ? path.substring(0, path.length() - 1) : path); return routes.get(fixedKey); } }RouteDefinition可以是一个非常简单的数据类,包含类类型、方法对象和参数信息。生产框架还会处理路径变量,例如/user/{id},但这里我们先聚焦最核心的精确匹配,确保整个流程完整。
路由表建立后,当 HTTP 请求到达时,我们只需要从HttpExchange里取出请求方法和路径,然后执行router.match,找不到就返回 404。
九、请求处理:从 HttpExchange 到方法调用
有了路由表,下一步是写核心分发器。它做的事包括:解析请求参数、处理请求体、注入方法参数、反射调用目标方法、处理返回值并写回响应。
java
import com.sun.net.httpserver.HttpExchange; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; public class Dispatcher { private final Router router; public Dispatcher(Router router) { this.router = router; } public void dispatch(HttpExchange exchange) throws Exception { String method = exchange.getRequestMethod(); String path = exchange.getRequestURI().getPath(); RouteDefinition definition = router.match(method, path); if (definition == null) { write(exchange, 404, "{\"code\":404,\"message\":\"not found\"}"); return; } Map<String, String> queryParams = parseQuery(exchange.getRequestURI().getRawQuery()); Object controller = definition.controllerClass().getDeclaredConstructor().newInstance(); Method targetMethod = definition.method(); Parameter[] parameters = targetMethod.getParameters(); Object[] args = new Object[parameters.length]; String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); for (int i = 0; i < parameters.length; i++) { Parameter parameter = parameters[i]; if (parameter.isAnnotationPresent(RequestParam.class)) { RequestParam requestParam = parameter.getAnnotation(RequestParam.class); String value = queryParams.getOrDefault(requestParam.value(), requestParam.defaultValue()); if (requestParam.required() && value.isEmpty()) { throw new IllegalArgumentException("缺少参数:" + requestParam.value()); } args[i] = convert(value, parameter.getType()); } else if (parameter.isAnnotationPresent(RequestBody.class)) { args[i] = JsonUtil.fromJson(body, parameter.getType()); } else { args[i] = null; } } Object result = targetMethod.invoke(controller, args); String json = JsonUtil.toJson(result); write(exchange, 200, json); } private Map<String, String> parseQuery(String rawQuery) { Map<String, String> result = new HashMap<>(); if (rawQuery == null || rawQuery.isEmpty()) { return result; } for (String pair : rawQuery.split("&")) { String[] kv = pair.split("=", 2); String key = java.net.URLDecoder.decode(kv[0], StandardCharsets.UTF_8); String value = kv.length > 1 ? java.net.URLDecoder.decode(kv[1], StandardCharsets.UTF_8) : ""; result.put(key, value); } return result; } private Object convert(String value, Class<?> type) { if (value == null) { return null; } if (type == String.class) { return value; } if (type == int.class || type == Integer.class) { return Integer.valueOf(value); } if (type == long.class || type == Long.class) { return Long.valueOf(value); } if (type == boolean.class || type == Boolean.class) { return Boolean.valueOf(value); } return value; } private void write(HttpExchange exchange, int status, String text) throws Exception { byte[] bytes = text.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json;charset=UTF-8"); exchange.sendResponseHeaders(status, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); } }至此,分发器把「HTTP 请求」和「Java 方法调用」之间的桥梁搭好了。你会发现,核心难度并不在注解本身,而在于围绕注解建立的解析、转换、调用、写回这一整套管道。唯一还没落地的是JsonUtil,接下来我们把它补齐,并继续往管理系统的完整能力延伸:统一响应、ORM 自动建表、通用 CRUD、权限拦截、参数校验、异步任务和配置读取。
十、JSON 序列化与反序列化:让返回值变得专业
前面 Dispatcher 依赖JsonUtil.toJson和JsonUtil.fromJson,这层能力必须由我们来补。生产环境强烈建议直接接 Jackson、Gson 或 fastjson,它们是久经考验的库,性能、边界情况和安全性都有保障。但为了把「注解框架」的原理讲透,这里给出一个只依赖反射的最简实现,帮助你看懂 JSON 工具背后的字段遍历过程。
java
import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.util.Collection; import java.util.Map; public class JsonUtil { public static String toJson(Object obj) throws Exception { if (obj == null) { return "null"; } if (obj instanceof String) { return "\"" + escape((String) obj) + "\""; } if (obj instanceof Number || obj instanceof Boolean) { return String.valueOf(obj); } if (obj instanceof Collection) { StringBuilder sb = new StringBuilder("["); boolean first = true; for (Object item : (Collection<?>) obj) { if (!first) sb.append(","); sb.append(toJson(item)); first = false; } sb.append("]"); return sb.toString(); } if (obj instanceof Map) { StringBuilder sb = new StringBuilder("{"); boolean first = true; for (Map.Entry<?, ?> entry : ((Map<?, ?>) obj).entrySet()) { if (!first) sb.append(","); sb.append("\"").append(escape(String.valueOf(entry.getKey()))).append("\":"); sb.append(toJson(entry.getValue())); first = false; } sb.append("}"); return sb.toString(); } StringBuilder sb = new StringBuilder("{"); Field[] fields = obj.getClass().getDeclaredFields(); boolean first = true; for (Field field : fields) { if (Modifier.isStatic(field.getModifiers())) continue; field.setAccessible(true); if (!first) sb.append(","); sb.append("\"").append(field.getName()).append("\":"); sb.append(toJson(field.get(obj))); first = false; } sb.append("}"); return sb.toString(); } public static <T> T fromJson(String json, Class<T> type) throws Exception { if (type == String.class) { return type.cast(json); } if (type == Integer.class || type == int.class) { return type.cast(Integer.valueOf(json)); } if (type == Long.class || type == long.class) { return type.cast(Long.valueOf(json)); } return type.getDeclaredConstructor().newInstance(); } private static String escape(String s) { return s.replace("\\", "\\\\") .replace("\"", "\\\"") .replace("\n", "\\n") .replace("\r", "\\r"); } }这个版本的toJson会递归处理字符串、数字、布尔、集合、Map 和普通对象;fromJson只做了基础类型的演示。真实项目里请不要在生产接口上使用这种「半成品」JSON 工具,直接引入成熟库即可。这里的价值在于让你明白:所谓框架魔法,拆开之后仍然是反射加递归。
十一、统一响应体与内置异常处理
管理系统的接口应该返回统一结构,而不能一会儿返回对象、一会儿返回字符串,更不能让调用方拿到裸异常和 500 堆栈。我们约定一个ApiResponse:
java
public class ApiResponse { private int code; private String message; private Object data; public ApiResponse(int code, String message, Object data) { this.code = code; this.message = message; this.data = data; } public static ApiResponse ok(Object data) { return new ApiResponse(200, "success", data); } public static ApiResponse error(int code, String message) { return new ApiResponse(code, message, null); } }然后在Dispatcher里加一个 try-catch,把业务异常统一转成规范的 JSON 响应:
java
try { Object result = targetMethod.invoke(controller, args); write(exchange, 200, JsonUtil.toJson(result)); } catch (IllegalArgumentException e) { write(exchange, 400, JsonUtil.toJson(ApiResponse.error(400, e.getMessage()))); } catch (Exception e) { write(exchange, 500, JsonUtil.toJson(ApiResponse.error(500, "server error"))); }到这里,框架在协议层已经像模像样了:请求进来按 URL 匹配方法,参数解析、反射调用、结果序列化、异常兜底全部有统一出口。下一步我们进入管理系统最耗时但也最值得自动化的部分:数据库。
十二、ORM 自动建表:用注解把实体映射成数据库表
管理系统离不开数据库。传统做法是先建表、再写实体、再写 DAO,字段一改到处改。我们要做的是:给实体类标上注解,框架启动时自动生成建表语句。先定义一组表映射注解:
java
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface Table { String name(); } @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface Column { String name() default ""; int length() default 255; boolean nullable() default true; } @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface Id { } @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface GeneratedValue { }然后你就能像写 POJO 一样声明一个用户实体:
java
@Table(name = "sys_user") public class User { @Id @GeneratedValue @Column(name = "id") private Long id; @Column(name = "username", nullable = false, length = 50) private String username; @Column(name = "password", nullable = false, length = 100) private String password; }框架一侧的SchemaBuilder通过反射读取上述注解,动态拼出CREATE TABLE:
java
import java.lang.reflect.Field; import java.sql.Connection; import java.sql.Statement; public class SchemaBuilder { public static void build(Class<?> entityClass, Connection connection) throws Exception { Table table = entityClass.getAnnotation(Table.class); if (table == null) return; StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS "); sql.append(table.name()).append(" ("); Field[] fields = entityClass.getDeclaredFields(); boolean first = true; for (Field field : fields) { field.setAccessible(true); if (field.isAnnotationPresent(Id.class)) { if (!first) sql.append(", "); if (field.isAnnotationPresent(GeneratedValue.class)) { sql.append("id BIGINT AUTO_INCREMENT PRIMARY KEY"); } else { sql.append("id BIGINT PRIMARY KEY"); } first = false; continue; } if (field.isAnnotationPresent(Column.class)) { if (!first) sql.append(", "); Column column = field.getAnnotation(Column.class); String name = column.name().isEmpty() ? field.getName() : column.name(); sql.append(name).append(" ") .append(field.getType() == Long.class || field.getType() == long.class ? "BIGINT" : "VARCHAR(" + column.length() + ")"); if (!column.nullable()) sql.append(" NOT NULL"); first = false; } } sql.append(")"); try (Statement statement = connection.createStatement()) { statement.execute(sql.toString()); } } }注意:前面 Web 容器可以做到零外部依赖,但到了持久层,JDBC API 虽然由 JDK 提供,真正连接数据库还需要一个驱动。示例中选型可以是 H2、SQLite 或 MySQL 驱动,按你的目标数据库取舍即可。注解驱动这个思路不变,变的只是底层驱动。
十三、通用 CRUD:注解一标,增删改查全有了
系统最烦人的是每个实体都写一遍增删改查。利用泛型加反射,我们可以让UserRepository只声明泛型参数,其余 SQL 全部自动生成。核心要点同样是读取@Table、@Id和@Column:
java
import java.lang.reflect.Field; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; public class BaseRepository<T> { private final Class<T> entityClass; private final Connection connection; public BaseRepository(Class<T> entityClass, Connection connection) { this.entityClass = entityClass; this.connection = connection; } public void insert(T entity) throws Exception { Table table = entityClass.getAnnotation(Table.class); StringBuilder columns = new StringBuilder(); StringBuilder marks = new StringBuilder(); boolean first = true; for (Field field : entityClass.getDeclaredFields()) { field.setAccessible(true); if (field.isAnnotationPresent(Id.class)) continue; // 自增主键跳过 Column column = field.getAnnotation(Column.class); String name = column.name().isEmpty() ? field.getName() : column.name(); if (!first) { columns.append(", "); marks.append(", "); } columns.append(name); marks.append("?"); first = false; } String sql = "INSERT INTO " + table.name() + " (" + columns + ") VALUES (" + marks + ")"; try (PreparedStatement ps = connection.prepareStatement(sql)) { int index = 1; for (Field field : entityClass.getDeclaredFields()) { field.setAccessible(true); if (field.isAnnotationPresent(Id.class)) continue; ps.setObject(index++, field.get(entity)); } ps.executeUpdate(); } } public T findById(Object id) throws Exception { Table table = entityClass.getAnnotation(Table.class); Field idField = findIdField(); String idColumn = idField.getAnnotation(Column.class).name(); if (idColumn.isEmpty()) { idColumn = idField.getName(); } String sql = "SELECT * FROM " + table.name() + " WHERE " + idColumn + " = ?"; try (PreparedStatement ps = connection.prepareStatement(sql)) { ps.setObject(1, id); ResultSet rs = ps.executeQuery(); if (rs.next()) { T entity = entityClass.getDeclaredConstructor().newInstance(); for (Field field : entityClass.getDeclaredFields()) { field.setAccessible(true); field.set(entity, rs.getObject(field.getName())); } return entity; } } return null; } private Field findIdField() { for (Field field : entityClass.getDeclaredFields()) { if (field.isAnnotationPresent(Id.class)) { return field; } } throw new IllegalStateException("未找到 @Id 主键字段"); } }上面的findById故意写得精简,重点展示「反射实体映射」这个动作。生产环境还需要处理下划线转驼峰、ResultSetMetaData动态取值、事务连接释放、分页和批量操作。你完全可以把这套逻辑替换成 MyBatis 或 JPA,注解声明式的思路依然成立。
使用起来非常简单:
java
Connection connection = dataSource.getConnection(); BaseRepository<User> userRepository = new BaseRepository<>(User.class, connection); User user = new User(); user.setUsername("admin"); user.setPassword("123456"); userRepository.insert(user);业务代码里几乎没有模板 SQL 了,这就是注解驱动带来的收益。
十四、权限拦截:用注解守住敏感接口
管理系统的接口不能谁都能调。最直观的做法是在方法上标注需要什么角色,由分发器在反射调用前先校验。先定义权限注解:
java
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresRole { String value(); }使用时标在敏感接口上:
java
@Controller("/user") public class UserController { @RequestMapping(value = "/delete", method = "POST") @RequiresRole("ADMIN") public ApiResponse delete(@RequestParam("id") long id) { return ApiResponse.ok("deleted"); } }在 Dispatcher 中,反射调用前先检查该方法是否带@RequiresRole,如果带了就从请求头或上下文中取出当前用户角色进行校验,不通过则直接返回 403:
java
if (targetMethod.isAnnotationPresent(RequiresRole.class)) { RequiresRole requiresRole = targetMethod.getAnnotation(RequiresRole.class); String currentRole = exchange.getRequestHeaders().getFirst("X-User-Role"); if (!requiresRole.value().equals(currentRole)) { write(exchange, 403, JsonUtil.toJson(ApiResponse.error(403, "无权限访问"))); return; } }这套机制虽然简单,但已经把「注解声明权限、框架统一拦截」这一核心思想表达出来了。真实项目里,角色可以从 JWT、Session 或网关透传的头信息中解析,校验逻辑也可以放到独立的拦截器链里。
十五、参数校验:把校验规则声明在参数上
参数校验同样适合用注解声明。定义一个@Min、@NotNull这类注解,在 Dispatcher 注入参数时顺带校验,就能避免在每个接口里写重复的 if 判断。这里以@Min为例:
java
@Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) public @interface Min { int value(); }在接口参数上声明:
java
@RequestMapping(value = "/list", method = "GET") public ApiResponse list(@RequestParam("page") @Min(1) int page, @RequestParam("size") @Min(1) int size) { return ApiResponse.ok(userService.page(page, size)); }Dispatcher 在完成参数转换后,检查参数上的校验注解:
java
if (parameter.isAnnotationPresent(Min.class)) { Min min = parameter.getAnnotation(Min.class); int intValue = (int) args[i]; if (intValue < min.value()) { throw new IllegalArgumentException( "参数 " + parameter.getName() + " 不能小于 " + min.value()); } }校验失败时抛出IllegalArgumentException,由前面统一异常处理转成 400 响应。这样参数的合法性约束就以注解的形式固定在接口签名上,既清晰又可维护。
十六、异步任务与配置读取
管理系统里经常会有一些耗时但不要求实时返回的任务,例如导出报表、发送通知。可以在方法上标注@Async,由框架把调用提交到线程池执行:
java
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Async { }Dispatcher 检测到@Async时,不直接在请求线程里执行,而是提交给线程池:
java
if (targetMethod.isAnnotationPresent(Async.class)) { taskExecutor.submit(() -> { try { targetMethod.invoke(controller, args); } catch (Exception e) { // 异步任务异常通常写入日志或落库 } }); write(exchange, 200, JsonUtil.toJson(ApiResponse.ok("任务已提交"))); return; }配置读取则可以借助 Java 的Properties或读取类路径下的配置文件,把数据库地址、端口、线程池大小等参数集中管理:
java
public class ConfigLoader { public static Properties load(String resource) throws Exception { Properties properties = new Properties(); try (var in = ConfigLoader.class.getClassLoader().getResourceAsStream(resource)) { properties.load(in); } return properties; } }在框架启动阶段读取配置,再据此初始化 HttpServer 端口、线程池大小、数据源连接等,就能实现「配置驱动」的启动过程。
十七、总结
到这里,一个极简但功能完整的注解驱动后端管理系统骨架已经搭起来了。回顾整个实现,它包含:
注解体系:
@Controller、@RequestMapping、@RequestParam、@RequestBody、@Table、@Column、@Id、@RequiresRole、@Min、@Async等,把业务意图声明在代码上。运行时机制:包扫描、路由注册、参数解析、反射调用、JSON 序列化、异常兜底、权限拦截、参数校验、异步执行。
数据层:注解驱动的自动建表和通用 CRUD,让实体定义与数据库结构保持一致。
这套实现虽然不如 Spring 完善,但它把「框架是怎么工作的」这一层完全暴露在你面前。当你理解了注解只是元数据、反射只是工具、真正让框架运转起来的是「扫描—注册—分发—调用」这一条链路之后,再去看 Spring Boot 的自动配置、MyBatis 的 Mapper 扫描,就不会再觉得神秘。