第二部分:框架内核

6 阅读52分钟

第二部分:框架内核

第五章 一切从那个决定开始——为什么手写IOC

本章核心:三百行核心代码的BeanFactory做三件事——实例化、注入、路由注册,后来未改核心逻辑。

2001年入行,政务信息化尚处C/S时代,C/S/S三层架构初现。亲历养老金社会化发放项目:大牛用C/C++手写TCP、私有协议对接PB,设计精巧,但数据库操作薄弱,系统频发崩溃;另一年轻同事用Java 1.3实现同类服务,凭稳定的数据库支持运行稳健。这次对比给我上了职业第一课:技术不在先进,稳定、能解决问题才是核心。

2004年,我用原始Servlet+JDBC+XML,手写极简分布式调度:XML配置功能ID,运行时Class.forName反射实例化调用。无框架、无完善封装,但支撑了早期政务系统稳定运行。

那时候没想太多,就觉得XML配个功能ID、反射调一下,够用了。谁能想到,这套土办法里的"功能ID映射到类实例、请求进来查Map找方法"的模式,会在多年后成为整个框架的核心骨架。

2011年,我接了一个某省编办的OA项目。招标文件写了:二次开发,必须使用甲方的框架。甲方用的SpringMVC。

项目推进到文件上传功能时,系统开始出问题——越来越慢,然后OOM。我dump了内存,用MAT分析,定位到commons-fileupload这个jar包。它当时那个版本有内存泄漏的Bug——不是Spring的问题,是Apache组件本身的bug,SpringMVC只是集成了它。

问题找到了,但改不了——那是甲方环境,升级jar包需要走变更审批,而且新版本不一定兼容甲方的SpringMVC版本。最后我绕开了它:自己写了一个文件上传组件,直接解析multipart流,1K缓冲区逐块写文件,不用任何第三方依赖。问题解决了。

这次经历让我第一次认真想一个问题:框架越强大,依赖链越长。commons-fileupload只是SpringMVC依赖树里的一片叶子,但它出了问题,排查链路要从你的代码→SpringMVC→commons-fileupload→Apache,追了好几层才找到根因。对我们这种小微企业来说,长依赖链是实实在在的负担。

手写IOC,原因只有一个:在我们的场景下,长依赖链对小微企业是实实在在的负担。

5.1 那个决定的起点

时间来到2012年。Spring已经很成熟了,3.x版本,社区活跃,文档齐全。任何一个Java开发者都会建议你:"用Spring啊,为什么不用Spring?"

我们也反复权衡过。最终答案是:因为在我们的场景下,Spring的"重"对我们这种小微企业恰恰是最大的负担。

部署环境混乱。 系统要部署到不同客户现场——有用Tomcat的,有用WebLogic的,还有国产中间件东方通、金蝶天燕、中创。Spring在这些环境下的配置方式各有差异,排查成本极高。

团队配置有限。 项目组通常就一两个人负责后端框架,不可能有专门的架构师来维护Spring的复杂配置。配置出了问题,就是我们来排查。但Spring内部的Bean生命周期、AOP代理机制、自动装配逻辑……排查难度较大。

需求很简单但很稳。 回头看,系统真正需要从容器获取的能力只有三个:实例化、属性注入、路由注册。Spring提供了几十个功能模块,我们可能只用了它不到5%的能力,却要承担100%的复杂度。

5.2 思考过程

面对"用不用Spring"这个问题,思考路径很清晰:

我们到底需要什么? 实例化——把带有注解的类new出来,放到一个Map里;属性注入——扫描字段上的注解,从Map里取对象塞进去;路由注册——扫描方法上的注解,把URL路径和方法对应起来。三件事,没了。

Spring能给我们什么? 实例化、属性注入、路由注册,还有:AOP、事务管理、数据访问、消息、安全、缓存、定时任务、配置管理、健康检查、Actuator……大概还有二十多个模块可能完全用不到。

Spring的代价是什么? 几十兆的jar包、XML配置或Java Config的维护成本、版本升级时的兼容性问题、部署环境差异导致的各种诡异Bug……

结论: 我们只需要IOC+路由,Spring提供了一整套完整的框架。在资源有限、环境复杂的政务场景下,引入Spring是"杀鸡用牛刀"——牛刀是好刀,但菜刀可能更顺手。

5.3 怎么做的

我编写了一个BeanFactory,三百多行核心代码,只做三件事。

三个注解定义一切:

@bean(id = "userService")

@property(type = "ref", value = "userMapper")  // 注入

@responseMapping(key = "/user")  // 路由前缀

@bean标在类上,BeanFactory启动时扫描指定包下所有带这个注解的类,反射创建实例,放到HashMap里。@property标在字段上,注入时从HashMap里取。@responseMapping标在类上做URL前缀,标在方法上做具体路径。

启动三步走: 第一步实例化所有带@bean注解的类;第二步扫描所有@property字段做依赖注入;第三步扫描所有@responseMapping方法注册路由到handlerMap。

就这么简单,启动时初始化,不担心多线程。所有bean都是单例——单例能扛并发,前提是bean本身无状态。请求级的私有数据(用户信息、数据库连接等)放在ThreadLocal的AppContext里,不在bean字段上。bean只装行为,不装状态,自然线程安全。

核心数据结构就是两个HashMap:

private static HashMap<String, Object> map = new HashMap<>();

private static HashMap<String, Class> clsMap = new HashMap<>();

getBean(key)就是map.get(key),一行代码。

5.3.1 实例化的真实过程

把三步走展开来看,源码比描述更有说服力。

BeanFactory构造函数接收一个包名,扫描包下所有带@bean注解的类。先检查id冲突——两个类用了同一个id,启动时直接报错退出:

bean d = (bean) cl.getAnnotation(bean.class);
if (map.get(d.id()) != null) {
System.out.println("bean冲突,冲突id为:" + d.id());
System.in.read();
System.exit(1);
}

这个设计是为了让基层运维人员能快速定位问题。启动阶段就暴露冲突,"不能启动"比"启动后行为异常"好排查一百倍。

接着是AOP代理的处理时机。如果一个类同时有@aoppoint和@responseMapping,说明它既是Controller又需要代理。这时候不在实例化阶段创建代理,等第三步路由注册完了再创建——因为代理是新对象,属性需要重新注入:

if (cl.isAnnotationPresent(aoppoint.class) && !cl.isAnnotationPresent(responseMapping.class)) {
map.put(d.id(), createproxy(cl));
} else {
map.put(d.id(), cl.newInstance());
}

启动时还顺手注册了@responseType(返回类型处理器:JSON、跳转、文件下载等)和@cache(缓存策略)。这些模块跟着容器启动一起完成,不需要额外配置文件。

5.3.2 属性注入:setter优先,反射兜底

getField()方法遍历所有带@property注解的字段,优先用setter方法注入:

private void getField(Class<?> clazz, Object instance) throws ... {
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
if (field.isAnnotationPresent(property.class)) {
property prop = (property) field.getAnnotation(property.class);
String key = field.getName();
String first = key.substring(0, 1);
first = first.toUpperCase();
key = first + key.substring(1);
Object paramValues1[] = new Object[1];
Class paramTypes1[] = new Class[1];
paramTypes1[0] = field.getType();
Method method1 = null;
try {
method1 = clazz.getMethod("set" + key, paramTypes1);
} catch (Exception e) {
method1 = null;
}
if ("ref".equals(prop.type())) {
paramValues1[0] = map.get(prop.value());
} else {
paramValues1[0] = prop.value();
}
if (method1 != null) {
method1.invoke(instance, paramValues1);
} else {
field.setAccessible(true);
field.set(instance, paramValues1[0]);
field.setAccessible(false);
}
}
}
}

先拼setter方法名("set" + 首字母大写 + 剩余),用getMethod()找。找到了调setter,找不到就field.setAccessible(true)直接设值。两种方式都支持,不管字段有没有写setter。type="ref"从容器取对象注入,其他值直接赋值。简单,但覆盖了所有场景。

5.3.3 路由注册:一个MethodMap装下所有信息

getMethodes()扫描类中所有带@responseMapping的方法,构建MethodMap对象注册到handlerMap:

private static void getMethodes(Object instans, String key, String id) {
Method[] methods = instans.getClass().getMethods();
if (methods != null) {
for (int i = 0; i < methods.length; i++) {
MethodMap mothodmap = new MethodMap();
if (methods[i].isAnnotationPresent(responseMapping.class)) {
responseMapping m = methods[i].getAnnotation(responseMapping.class);
String connext = key + m.key();
List parametertypeList = getMethodInfo(methods[i]);
List parameterGenerictypeList = getMethodGenericInfo(methods[i]);
List parameterNameList = getMethodParameterNamesByAsm4(instans.getClass(), methods[i]);
mothodmap.setBeanid(id);
mothodmap.setMothod(methods[i]);
mothodmap.setParameterNameList(parameterNameList);
mothodmap.setParametertypeList(parametertypeList);
mothodmap.setParameterGenerictList(parameterGenerictypeList);
handlerMap.put(connext, mothodmap);
}
}
}
}

每个MethodMap保存了方法反射所需的一切:bean id、Method对象、参数类型列表、泛型类型列表、参数名列表。参数名通过ASM字节码读取(第五章展开)。注意mothodmap这个变量名——源码里就是少写了个e,当年写的代码,跑了十几年也没改。前言里已经交代过,这些不规范的命名是当时的真实状态,保留是为了还原历史,不是提倡这种风格。

注册完路由后,如果Controller有@aoppoint注解,用代理对象替换原实例,重新注入属性:

if (entry.getValue().getClass().isAnnotationPresent(aoppoint.class)) {
Object proxy = createproxy(entry.getValue().getClass());
getField(entry.getValue().getClass(), proxy);
map.put(entry.getKey().toString(), proxy);
}

5.3.4 bean.xml 兼容:适配不同部署环境

有些老项目的部署环境还在用XML配置方式,又信创改造时中间件可能要求XML。所以BeanFactory保留了对bean.xml的支持:

try {
initFrombeanXml();
} catch (DocumentException e) {
e.printStackTrace();
}

initFrombeanXml()用dom4j解析XML,把bean配置加到同一个HashMap里。注解方式和XML方式共享一个容器,不存在两套体系。环境锁要求你的框架能适应不同客户现场——有的地方能跑注解扫描,有的地方只能读XML配置文件,你的框架得都支持。

5.3.5 业务代码最终长什么样

所有基础设施加在一起,业务代码写起来非常干净:

@bean(id = "userService")
@responseMapping(key = "/user")
public class UserService {

    @property(type = "ref", value = "userMapper")
private UserMapper userMapper;

    @responseMapping(key = "/query")
@Trans(readonly = true)
public DataCenter queryUser(DataCenter dc, HttpServletRequest request,
HttpServletResponse response) {
// 直接写业务逻辑,不需要任何框架相关的代码
}
}

加个@bean,容器管生命周期;加个@property,依赖自动注入;加个@responseMapping,路由自动注册。不需要额外配置文件。@bean注解还支持singleton参数——默认true,单例模式。胆最终我也没有支持多例模式,没那么复杂的场景。

5.4 这个决策的深层逻辑

如果你只看到"手写了一个简易IOC",那你可能觉得这是什么垃圾,没事造啥轮子。但请别急,这个决策背后的逻辑是:

可控性 > 功能性。 Spring提供了无数强大功能。但当出了问题时,你需要理解Spring内部的工作机制才能排查——就像本章开头那个commons-fileupload内存泄漏的经历。问题出在第三方jar里,你定位到了也改不了。在两三个人的团队里,可控性比功能性重要得多——我们需要能在10分钟内定位问题,病人就在医院等着结算,不能花半天时间研究别人的源码。

零依赖 > 丰富生态。 我们的框架只依赖自己的代码+MyBatis+CGLIB。没有Spring就没有版本兼容问题,没有类加载冲突问题,没有"换了个中间件就不工作"的问题。

够用就好 > 大而全。 只加需要的不加不需要的。MongoDB混合存储、Excel模板报表、Activiti任意流转……每一个方案都是这个思路。

5.5 另一面

我们必须坦诚,这个决策有明显的代价:

• 没有Spring的生态,得自己造很多轮子;

• 新人加入项目需要学习成本——市面上没有书教你用这个框架;

• 无法享受Spring社区的安全更新和Bug修复;

• BeanFactory是单线程启动的,bean数量多了启动会慢——我们最大的项目也就几百个bean,感知不到,但规模再大就是问题;

• 没有循环依赖检测——Spring会自动处理循环引用,我们不会,但我们只有单例,先创建,再set,所以没踩过坑。

这些代价在资源充裕的团队或业务快速迭代的场景下可能是难以接受的。但在特定的政务场景下,它们是合理的权衡。

框架稳定运行了14年,支撑了省级新农保、跨省医保结算等核心民生系统。当然,用Spring完全可以做到同样的稳定——Spring 3.x至今依然能跑,没有人逼着你升级。问题在于Spring的生态系统会不断"诱惑"你引入更多功能:Spring Security、Spring Data、Spring Cloud……每多引入一个模块就多一分版本耦合风险,多一个部署依赖,多一层排查复杂度。在政务场景下,我们的项目组就两三个后端,维护一个日益膨胀的Spring全家桶的代价,远高于维护三百行自研代码。我们选择不引入整个生态,只因为我们的场景承载不起它。

而我们的BeanFactory,三百多行核心代码,14年没改过核心逻辑。稳定的反面是简单,简单的反面是可控。

5.6 回头看

回顾这一章,忽然想起点什么。开始提到2004年那套XML+反射的调度逻辑——功能ID映射到类实例,反射调用。跟后来BeanFactory和route.java的核心逻辑,本质上没有区别。区别只在于:2004年是手写XML配置、直接newInstance,后来换成了注解、IOC。

2001年见证了Java的稳定,2004年验证了反射调度的可行性,2008年逼出了行状态机(第二章),2012年才把这些年的认知拧成了一根绳。手写IOC从来不是突然的决定,而是我从2001年一路走来的必然。

老框架搭好了,新代码能跑了。但老系统还在线上服务几百万参保人——推倒重来不是选项。怎么让新老代码和平共处?

5.7 怎么做的

在route.java的service()方法里做了一个非常简单的判断——先尝试注解路由,找不到就走老路由:

核心逻辑就三步:先调praserHandler()查注解路由表,找到null就调readDc()+oldinoke()走老路由;找到了就调praserInParameter()解析参数、BeanFactory.getBean()获取实例、method.invoke()执行方法。

就这么一个null判断,新老路由就和谐共处了。

praserHandler()从URL中解析路径,在handlerMap里查找。找到了就返回MethodMap(包含目标方法的所有信息),找不到就返回null——null就是信号:"这个请求没有注解路由,走老的吧"。

service()方法的路由分发之外,还有几件统一处理的事:

登录校验。 第一件事是从Session取用户信息,空就跳登录页。不管新路由还是老路由,校验只有一个入口。

AppContext。 在路由分发之前就把用户、部门、方法名、bean id全部塞进AppContext,绑定到ThreadLocal:

context.setUser((User) session.getAttribute("user"));
context.setCurrDetp(power);     // 当前用户所属部门
context.setBAZ098(method1.getName());     // 方法名
context.setBAZ099(mothodmap.getBeanid()); // bean id
context.setContext(this.getServletContext());
AppContextContainer.setAppContext(context);

说实话这不太规范——社保项目没有药房字段,医保项目不一定有部门字段。不是对应项目的字段就是空的,业务代码也不会去用它。但就这样塞一个统一的上下文里,所有项目共享同一个AppContext,各取所需,跑了好多年也没出过问题。

异常处理。 整个方法体只有一个try-catch,任何异常都走同一段错误处理逻辑。finally里一定调用context.delSession()关闭数据库连接——不管正常执行还是抛异常,连接都要释放。

5.9 praserHandler() :URL拆分定位路由

路由解析的具体实现:

private MethodMap praserHandler(HttpServletRequest request) {
String url = request.getRequestURI();
String temp[] = url.split("/");
if(temp == null || temp.length <= 3) {
return null;
}
url = "";
for(int i=3;i<temp.length;i++)
url += "/" + temp[i];
MethodMap mothodmap = handlerMap.get(url);
return mothodmap;
}

按/拆分URL,去掉前三段(通常是/项目名/模块/...),剩余拼接成路径,从handlerMap查找。找不到返回null,service()就知道要走老路由。逻辑非常直白,运维人员看日志就能理解路由为什么没匹配上。

5.10 统一返回处理

不管走新路由还是老路由,方法有返回值时统一交给responseFactory处理:

if(obj != null) {
responseFactory.getInstance().getTypeHandler
(mothodmap.getReturnHandler()).parse(obj,request,response);              
}

mothodmap.getReturnHandler()指定的返回类型是通过@responseType注解在方法上标注的——JSON、URL跳转、文件下载等。返回处理也是新老路由共享的,不存在两套序列化逻辑。

5.11 渐进式迁移的实际操作

有了这个兼容机制,迁移就是渐进式的:新开发的模块全部用注解路由;老模块有空就改一个,改完测试通过就上线;某天所有模块都迁完了,老路由代码自然就成了死代码,可以安全删除。

我们当时定了一个规矩:每周只迁两个模块,周二迁一个周四迁一个,迁完跑一周没投诉再迁下一批。 这个节奏看起来慢,但保证了零故障。整个迁移持续了三个月左右,线上系统始终运行。

5.12 决策背后的WHY

为什么不做"大爆炸"式的迁移? 因为风险不可控。政务系统覆盖几百万居民,回归测试的遗漏可能造成生产事故。渐进式迁移的风险是可控的——每次只改一个模块,改一个测一个,出了问题影响范围小。

为什么不维护两套框架? 因为维护成本太高。两套路由、两套参数解析、两套异常处理……复杂度是指数级增长的。共用一个route.java做分发,新老路由共享登录校验、异常处理、连接管理,维护成本最低。

为什么不直接用Spring MVC? 因为那意味着要把所有老代码都改成Spring风格——Controller用@RestController,依赖注入用@Autowired,参数绑定用@RequestParam……改造范围比路由迁移大得多。

一句话:兼容共存、渐进迁移,通常是政务系统迭代升级最为安全的路径。

务实主义倾向于渐进演进,而非"推倒重来",而是在存量中找增量。能在老系统不停机的前提下平滑演进,是政务信息化中非常重要的能力。

本章小结

• BeanFactory三百行代码搞定IOC:实例化、属性注入、路由注册。

• 可控性 > 功能性。300行的代码,任何时候出了问题你都敢翻。

• 新老路由兼容只需一个null判断:注解路由找不到就走老路由。

• 渐进式迁移:每周迁两个模块,三个月完成,零故障。

决策洞察:你只需要5%的功能,却要承担100%的依赖链。改造老系统最重要的不是新技术多强大,是退路多清晰——随时能回滚,才算安全。

第六章 前后端解耦——让前端只管发JSON

路由搞定了(上一章),新代码能跑了,老代码也能跑了。但前端同事来找我:"你后端方法一会儿要DataCenter,一会儿要UserDao,一会儿要List——我前端怎么知道该传什么格式?"

本章核心:框架做"翻译层",前端统一POST JSON,后端方法签名随意,七种参数类型自动转换。

前端完全不知道后端的方法签名长什么样。

但它就是能正确传参。每次都对。

来看一个后端方法签名:

public DataCenter save(DataCenter dc, HttpServletRequest req) { ... }

再看另一个:

public void update(UserDao user) { ... }

还有一个:

public void batchSave(List users) { ... }

三种完全不同的参数类型——有的是DataCenter,有的是自定义Dao对象,有的是List。前端不知道这些,也不需要知道。它只做一件事:POST一段JSON。框架自动把JSON"翻译"成后端方法需要的参数类型。

这是怎么做到的?

6.1 怎么做的

praserInParameter()方法——遍历方法的参数类型列表,对每个参数做不同的转换处理:

• 参数类型是HttpServletRequest/HttpServletResponse?直接透传;

• 参数类型是DataCenter?JSON反序列化成DataCenter;

• 参数类型是自定义Dao(包名以com.browise开头)?JSON反序列化成Dao对象;

• 参数类型是List?根据泛型信息做JSON数组转换;

• 参数类型是普通类型(String、int等)?从URL参数取值做类型转换。

这里的关键问题是:参数类型从哪来?方法签名在编译后参数名会丢失(变成arg0, arg1),但参数类型不会丢失——method.getParameterTypes()始终可用。所以praserInParameter()先拿到参数类型列表,然后根据类型决定转换策略。至于参数名,那是下一节要解决的问题——框架需要参数名才能从JSON里按名字取值。

转换的核心代码逻辑(简化版,保留了完整的分支结构):

public static Object[] praserInParameter(HttpServletRequest request,
HttpServletResponse response,
MethodMap mothodmap) throws TypeException, IOException {
int parameterCount = mothodmap.getParametertypeList().size();
Object[] paramValues = new Object[parameterCount];
String dc = "";
boolean isread = false;  // request输入流只能读一次

    for(int i = 0; i < parameterCount; i++) {
Class cls = mothodmap.getParametertypeList().get(i);
Object temp = null;

        // 类型一:Servlet对象直接透传
if(cls == HttpServletRequest.class) temp = request;
else if(cls == HttpServletResponse.class) temp = response;

        // 类型二:HashMap处理文件上传(multipart解析)
else if(cls == HashMap.class)
temp = TypeHandlerFactory.newInstance().getTypeHandler(cls).parse(request);

        // 类型三:List——根据泛型信息做JSON数组转换
else if(cls == List.class) {
if(!isread) { dc = readDc(request); isread = true; }
temp = TypeHandlerFactory.newInstance().getTypeHandler(cls)
.parse(dc, mothodmap.getParameterGenerictList().get(i));
}

        // 类型四:newDataStore——框架的分页数据结构
else if(cls == newDataStore.class) {
if(!isread) { dc = readDc(request); isread = true; }
temp = TypeHandlerFactory.newInstance().getTypeHandler(cls)
.parse(dc, mothodmap.getParameterNameList().get(i),
mothodmap.getParameterGenerictList().get(i));
}

上半部分处理四种不需要JSON体的参数。注意isread标志——request的输入流只能读一次,方法可能有多个参数都需要从JSON取值,所以只读一次存到dc变量,后续所有参数复用。这不是优化,是正确性要求。

下半部分处理三种需要JSON体的参数:

        // 类型五:DataCenter——JSON整体反序列化
else if(cls == DataCenter.class) {
if(!isread) { dc = readDc(request); isread = true; }
temp = TypeHandlerFactory.newInstance().getTypeHandler(cls).parse(dc);
}

        // 类型六:自定义Dao(com.browise包下)——JSON按字段映射
else if(cls.getName().startsWith("com.browise")) {
if(!isread) { dc = readDc(request); isread = true; }
temp = TypeHandlerFactory.newInstance().getTypeHandler(cls).parse(dc, cls);
}

        // 类型七:普通类型(String、int等)——从URL参数取值做类型转换
else {
temp = request.getParameter(mothodmap.getParameterNameList().get(i));
temp = TypeHandlerFactory.newInstance().getTypeHandler(cls).parse(temp);
}

        paramValues[i] = temp;
}
return paramValues;
}

每个分支最终都委托给策略工厂TypeHandlerFactory,扩展新参数类型只需新增Handler。

newDataStore是框架特有的分页数据结构,比DataCenter多携带了分页信息(页码、每页条数、总记录数)。政务系统的查询页面几乎都要分页,统一成数据结构后业务方法直接拿分页数据。

6.2 决策背后的WHY

为什么不让每个方法自己解析参数? 因为重复代码太多。每个方法的前几行都是"从request取值→类型转换→拼装对象"。这些当然是框架该做的事,不该推给业务开发人员。

为什么不统一参数类型? 比如规定所有方法只接收DataCenter。技术上可以,但不够灵活。有些方法确实只需要一个UserDao对象,强行用DataCenter包装一层是"为了统一而统一"。

为什么前端统一POST JSON? 这是2010年前后的技术决策。当时Ajax刚普及,表单提交的GET/POST方式参数格式混乱(URL编码、multipart、JSON混用)。统一POST JSON是最简单的前端规范。

一句话:框架做中间的"翻译层",前端和后端各自用自己最舒服的方式工作。这个原则贯穿了整个框架的设计。

让每一方做最擅长的事,不让开发者为基础设施的摩擦力买单——这也是务实主义的一种:用"隔离"来消除"约束"。

但还有一个前提问题:框架怎么知道参数名叫什么?前面说过,Java编译后参数名会丢失,变成arg0, arg1。praserInParameter()需要参数名才能从JSON里按名字取值。这个问题必须解决。

6.3 ASM 的思考过程

能不能要求所有人加-parameters? 技术上可以,但执行不了。政务项目的开发团队是分散的——有的是我们的人,有的是客户的人,有的是外包团队。分散的开发团队很难保证统一的编译配置,即使今天统一了,新加入的人可能又不一致。

能不能在代码里用注解指定参数名? 比如@Param("name") String name。可以,但太啰嗦了。每个参数都要加一个注解,方法签名本来就够长了,再来一排注解,代码丑得没法看。

能不能从反射获取? method.getParameters()在Java 8以上可以拿到参数名,但前提是编译时加了-parameters。没加的话,拿到的还是arg0, arg1。

能不能直接读class文件? Java编译器在编译时,默认会把参数名写入class文件的LocalVariableTable属性中(调试信息)。只要编译时没显式去掉调试信息(-g:none),这个表就存在。用ASM直接读字节码,就能拿到参数名。

这条路可行。 不依赖编译选项,不依赖Java版本,只要class文件有调试信息(默认就有),就能可靠地拿到真实参数名。

6.4 ASM的实现

BeanFactory里有一个静态方法getMethodParameterNamesByAsm4(),用ASM直接读class文件的字节码:

public static List getMethodParameterNamesByAsm4(
Class clazz, final Method method) {     final Class[] parameterTypes = method.getParameterTypes();
if (parameterTypes == null || parameterTypes.length == 0) return null;

    final Type[] types = new Type[parameterTypes.length];
for (int i = 0; i < parameterTypes.length; i++)
types[i] = Type.getType(parameterTypes[i]);

    final List parameterNames = new ArrayList<>();
String className = clazz.getName();
className = className.substring(
className.lastIndexOf(".") + 1) + ".class";
InputStream is = clazz.getResourceAsStream(className);

    // 用ASM读取字节码
ClassReader classReader = new ClassReader(is);
classReader.accept(new ClassVisitor() {
@Override
public MethodVisitor visitMethod(int access, String name,
String desc, String signature, String[] exceptions) {
// 按方法名+参数类型匹配目标方法
Type[] argumentTypes = Type.getArgumentTypes(desc);
if (!method.getName().equals(name)
|| !Arrays.equals(argumentTypes, types))
return null;

            return new MethodVisitor() {
@Override
public void visitLocalVariable(String name, String desc,
String signature, Label start, Label end, int index) {
// 静态方法第一个就是参数,非静态方法的第一个是this
if (Modifier.isStatic(method.getModifiers()))
parameterNames.add(name);
else if (index > 0)
parameterNames.add(name);
}
};
}
}, 0);
return parameterNames;
}

有一个细节:静态方法的第一个局部变量就是第一个参数,而非静态方法的第一个局部变量是this(index=0),要跳过它。

读字节码听起来很"重",但它换来的是业务开发零感知——参数名已经写在代码里了(String name),再写一遍@Param("name")是重复劳动。框架应该自动获取这个信息,不该推给使用者。

本章小结

• 前端统一POST JSON,后端方法签名随意——框架自动做"翻译"。

• 统一入参解析(praserInParameter())根据参数类型自动转换:DataCenter、Dao、List、基础类型。

• 前后端解耦的核心:框架做中间层,让前端和后端各自用最舒服的方式工作。

决策洞察:解耦不是让双方都懂对方,是让双方都不需要懂对方。

第七章 轻量AOP——一个jar搞定事务、日志、监控

7.1 前史:2008年那个不知道叫AOP的AOP

本章核心:CGLIB代理+责任链,一个jar搞定事务、审计日志、慢SQL检测——103行TuningUtil。

第二章讲过那个社保三级审核项目。我的做法:重写uo_datawindow(继承PB的datawindow)update事件,不调用父类的Update(),自己遍历行状态拼SQL,写到审核中间表。 数据不写到原来的业务表,写到结构和业务表一模一样的审核表(KC22→KC22_audit),审核通过后再写回业务表。老业务代码一行没改。

AOP概念我的实现
切面(Aspect)uo_datawindow重写的update事件
切入点(Join Point)dw_1.Update()这个调用
通知(Advice)拦截后拼SQL写到审核表,而不是原表
织入(Weave)继承uo_datawindow,所有窗口用子类代替父类

不碰业务逻辑,在"保存"这个动作的前后插入自己的逻辑。 这就是AOP。但2008年,我不知道AOP是什么——改老代码代价太高,只能从外面切。这个决定影响了我后面十几年的框架设计。

7.2 场景还原

事务管理、日志记录、性能监控——这些横切关注点不应该写在每个业务方法里。但加这些又不想引入AspectJ那套复杂的机制。

我们的框架没有Spring,没有AspectJ。手边只有Java标准库、MyBatis和CGLIB。能用的工具就这么几个。

7.3 怎么做的

技术选型三句话说完:业务类有的没有接口,JDK动态代理用不了,只能CGLIB;AspectJ需要编译时织入,部署配置复杂,一个CGLIB的MethodInterceptor就够了;事务、日志、监控有先后顺序,用责任链模式,新增拦截器只需加一个类。

三个注解:@Trans、@Logger、@monitoring。核心是doProxy.java——遍历方法上的注解,动态组装责任链。

三个拦截器各有分工:

transInterceptor负责事务——BeginTrans开启,执行业务方法,EndTrans提交,异常时rollback。finally里还有无条件rollback,确保连接归还连接池时没有残留事务。

logInterceptor负责日志——记录类名、方法名、参数值。只在debug级别开启时才拼接参数字符串,避免生产环境的无用性能开销。

monitorInterceptor负责监控——记录方法开始和结束时间,监控数据持久化到数据库。保存失败只记error日志,不抛异常。

链条顺序取决于注解声明顺序:谁写在最后谁就是最外层(最先执行,最后退出)。proxyFilter过滤不需要代理的方法:@aoppoint(filter = "getQuery")表示getQuery方法不走代理。

7.4 事务提交的原子审计——commit里写日志,同生共死

上一节讲了三个拦截器。transInterceptor管事务,logInterceptor管日志。但政务系统有一种更严格的日志——审计日志。

审计日志不是调试日志。它的要求是:谁在什么时间、对哪条数据、做了什么操作,必须完整记录。而且审计日志不能因为业务回滚就丢了——业务回滚了,审计记录也得留着,否则出了问题无从追溯。

常见的方案是开两个事务分别写,但两个事务之间无法保证原子性。要么用分布式事务(XA),太重;要么用消息队列异步写,有延迟,而且政务内网环境部署消息队列本身也是个大工程。

我的方案:在业务事务的commit方法里调存储过程写审计日志,存储过程内部用自治事务(PRAGMA AUTONOMOUS_TRANSACTION)。 调用入口在业务连接上,保证了审计一定会被执行;存储过程内部开独立事务写日志,保证了即使业务回滚,日志也保留。

框架里封装了一个BusinessSession,它包装了MyBatis的SqlSession,核心是commit()方法:

// BusinessSession.java
public class BusinessSession {
private String BAZ001;        // 操作ID
private SqlSession session;   // MyBatis的SqlSession
private boolean saveLog = true; // 是否保存日志,默认保存
private User dao;             // 当前用户

    public BusinessSession(boolean isModefy) throws utilException {
if (isModefy) {
this.BAZ001 = SystemUtil.getSeq("S_SEQ_BAZ001");
}
}

    public void commit() throws utilException {
// 先调用审计日志的存储过程
if (saveLog) {
Map<String, String> param = new HashMap<String, String>();
param.put("PRM_OPERATEID", BAZ001);
param.put("PRM_AAE011", dao.getPsn_name());
param.put("PRM_AAE036", "");

            session.selectOne(
"com.browise.core.util.mapper.sysUtilMapper.DO_TRANSACTION",
param);

            if (Integer.parseInt(
String.valueOf(param.get("PRM_APPCODE"))) < 0) {
throw new utilException(
"写日志失败"+param.get("PRM_ERRORMSG"),-100);
}
}
// 日志写成功,再提交业务事务
this.session.commit();
}
}

commit()先调存储过程DO_TRANSACTION写审计日志(自治事务,日志写在独立事务里),再提交业务数据。存储过程失败则业务也回滚,但日志可能已写入(自治事务保证)。

业务回滚了日志不丢,业务提交了日志一定在。 不需要分布式事务,不需要消息队列。103行代码解决了政务审计的核心痛点。

DO_TRANSACTION是一个Oracle存储过程,根据PRM_OPERATEID找到本次操作涉及的所有表和记录,从临时表搬到审计表。这些临时记录是框架在业务操作过程中自动写入的——第八章讲的Row状态机,每条被修改的Row都会自动记录操作类型和原始值,commit时存储过程一次性将它们归档到审计表。

saveLog标志位的设计也有讲究。有些操作不需要写审计日志——比如下一节讲的慢SQL检测,它写日志本身如果再触发审计,就递归了。这时设session.setSaveLog(false)就行。

7.5 不上APM的慢SQL检测——103行代码搞定

上一节的审计日志是安全层面的刚需。这一节讲一个运维层面的实战问题。

政务系统上线后,偶尔有用户反馈"页面卡"。但到底卡在哪里?上APM(应用性能监控)当然能解决,但政务内网部署APM本身就是一个大工程——SkyWalking依赖Elasticsearch,Pinpoint依赖HBase。我的方案:框架层埋计时器,超过阈值的SQL自动记到数据库表里。@monitoring注解配合TuningUtil实现:

public class TuningUtil {
private long start_time = 0;
private long end_time = 0;
private long inteval = 100; // 超出100毫秒就记录
private String MethodName = "";
private String Oper = "";

    // 监控开始——在SQL执行前调用
public void SetMonitorBegin(String MethodName,
Object params[], String Oper){
this.start_time = System.currentTimeMillis();
this.MethodName = MethodName;
this.Oper = Oper;
}

    // 监控结束——在SQL执行后调用
public void DoTuning() throws Exception{
this.end_time = System.currentTimeMillis();
long li = end_time - start_time;
if (li > inteval){
// 获取实际执行的SQL
IbatisSql sql = DBUtil.getIbatisSql(
null, MethodName, null);
String mySql = sql.getSql();
mySql = mySql.replace("'", "");
mySql = mySql.replace(""", "");
if (mySql.length() > 4000){
mySql = mySql.substring(0, 4000);
}
// 写入TUNINGEVENT表
String ColumnValue = "'"+MethodName+"'"+","+"'"+mySql+"'"
+","+"'"+Oper+"'"+","
+"to_date('"+s1+"','yyyy-mm-dd HH24:mi:ss')"
+","+Long.toString(li);
// ...插入语句省略...
}
}
}

inteval硬编码100毫秒,政务OLTP场景的合理分界线。慢SQL记录用DoTransaction单独开Session写,不走业务事务——业务事务可能正因为慢SQL而回滚。设setSaveLog(false)防止写监控记录时触发审计日志递归。

SQL从哪来。 DBUtil.getIbatisSql()是框架封装的方法,能从MyBatis配置中根据方法名解析出实际执行的SQL语句(包含参数绑定后的完整SQL)。过滤了单双引号(防注入),截取了4000字符(Oracle的VARCHAR2限制)。

通过AOP调用。 TuningUtil配合@monitoring注解使用。标注了@monitoring的方法,AOP代理自动在方法执行前后调用SetMonitorBegin和DoTuning。业务代码完全无感知。

上线后第一次查TUNINGEVENT表就发现了一个跑了8秒的报表查询。加了索引后降到200毫秒。这就是框架层统一埋点的好处——你不需要知道哪个SQL慢,框架帮你盯。

本章小结

• 三个注解(@Trans、@Logger、@monitoring)+ CGLIB代理 + 责任链,用最简单的方式实现AOP。

• AOP的起源:2008年PB项目里重写DataWindow的Update拦截SQL,当时不知道这叫AOP。

• 注解顺序决定责任链顺序,业务代码保持干净,横切关注点统一管理。

• BusinessSession.commit()先写审计日志再提交,同一连接天然原子,解决政务审计核心痛点。

• TuningUtil + @monitoring实现无APM的慢SQL检测,阈值100毫秒,自动入库,运维查表即知。

TuningUtil vs APM(SkyWalking/Pinpoint)对比:

维度SkyWalkingTuningUtil说明
功能范围分布式链路追踪+JVM监控+告警慢SQL入库政务系统一般有慢SQL检测就可以满足需要了
部署依赖Elasticsearch+Agent零外部依赖政务内网搭ES成本极高
运维要求专人维护ES集群一张表+查询页面区县运维查表就会
代码量数十万行(整个平台)百行级别可审计、可理解
性能开销Agent字节码注入,约5%损耗方法级耗时判断,<1%老服务器上每一份性能都珍贵
适用场景微服务架构、分布式系统单体应用、SQL性能排查系统架构决定监控选型

决策洞察:横切关注点(事务、日志、监控)不应该出现在业务代码里,但也不需要引入一个框架来管理它们——200行自己的代码就够了。

第八章 框架基础设施——上下文管理与定时任务

业务方法需要当前用户、数据库连接等信息,但不能依赖Servlet API。定时任务也是刚需,但不想引入Quartz。这两个问题解决方案不同,但思路一样:用最简单的方式搞定,零外部依赖。

8.1 ThreadLocal上下文——业务层不该知道请求从哪来

业务方法经常需要当前用户信息、数据库连接等。最直接的方式是传HttpServletRequest,但这样业务代码就和Servlet API强耦合了。如果要在非Web环境下调用(比如定时任务),就得改签名。

解决方案:ThreadLocal。上下文数据是当前线程需要的临时数据,生命周期和请求一致。Web容器每个请求一个线程,正好对应。

AppContextContainer——继承ThreadLocal的单例容器:

public class AppContextContainer extends ThreadLocal {
private static AppContextContainer container = new AppContextContainer();

    public static AppContext getAppContext() {
if (container.get() != null) return container.get();
AppContextImpl impl = new AppContextImpl();
container.set(impl);
return impl;
}

    public static void setAppContext(AppContext context) {
container.set(context);
}
}

AppContextImpl保存了整个请求周期需要的所有信息——用户、bean id、方法名、数据库连接列表、部门、DataStore缓存。

上下文在第五章的service()方法里设置(路由分发之前),在finally块里清理。连接的生命周期和请求绑定:业务方法通过DBUtil.BeginTrans()获取连接时放入列表,请求结束时delSession()统一关闭。如果连接泄漏了,delSession()关闭时会打印bean id和方法名,精准定位。

业务层只需要:

// 获取当前用户——不需要HttpServletRequest
User user = AppContextContainer.getAppContext().getUser();

// 获取当前部门
String dept = AppContextContainer.getAppContext().getCurrDetp();

业务方法不应该关心数据"从哪来",只需要知道"现在有什么"。用ThreadLocal在框架层把上下文打包好,业务代码只管取用。

8.2 不上Quartz——数据库表驱动的定时任务

前面讲的都是框架如何处理Web请求。但政务系统里还有一类需求不受HTTP触发——定时任务:定时同步数据、定时生成报表、定时清理过期记录。这些任务的执行周期不是固定的,可能今天每天跑一次,明天改成每小时跑一次。

Quartz功能强大但引入了一大堆依赖,在政务内网环境下部署和配置都很麻烦。而且政务运维人员更习惯在页面上直接管理任务,不是改XML配置文件。

我们的方案:在数据库里建一张任务表,用JDK自带的java.util.Timer+反射实现调度。启动时读表,运行时通过反射实例化任务类,按周期调度。

// timerContral.java
@aoppoint
@bean(id="timer")
@responseMapping(key="/timer")
public class timerContral {

    // 启动所有定时任务
@Trans
@monitoring
@responseMapping(key="/start")
public void start() throws utilException {
Timer timer = TimeListener.getTimer();
mytimer dao = new mytimer();
dao.setSearchMethod("selectAll");
List list = (List) dao.search();
for(int i = 0; i < list.size(); i++) {
long period = list.get(i).getPeriod() * 1000;
Date first = list.get(i).getBegintime();
Date now = new Date();
if(first == null) first = now;
if(first.getTime() < now.getTime()) {
first = new Date(now.getTime() -
((now.getTime() - first.getTime()) % period)
+ period);
}
String clsName = list.get(i).getClassname();
timer.schedule(
(TimerTask)Class.forName(clsName).newInstance(),
first, period);
}
}

    // 停止所有定时任务
@monitoring
@responseMapping(key="/stop")
public void stop() { TimeListener.cancel(); }
}

核心就一行:Class.forName(clsName).newInstance()。数据库里存的是类的全限定名,运行时反射实例化,强转成TimerTask,交给Timer.schedule。任务类只需要继承java.util.TimerTask,实现run()方法。

首次执行时间的计算也值得提一笔。如果配置的首次执行时间已经过了,不是"立刻执行",而是按周期计算下一个执行时间。比如配置了每天夜间2点执行,现在是下午3点,就算出明天夜间2点再开始。这行取模运算把周期对齐了。

这个类同时也是一个标准的框架业务控制器——标注了@aoppoint(AOP代理)、@bean(IoC注册)、@responseMapping(路由映射)。方法上标注了@Logger(自动日志)、@Trans(自动事务)、@monitoring(自动监控)。一个定时任务管理类,同时享受了IoC、AOP、事务、日志、监控全套框架能力。

运维人员通过页面的CRUD接口管理任务配置,不需要碰代码、不需要改配置文件、不需要重启服务器。任务表里至少有三个字段:classname(类名)、period(周期,秒)、begintime(首次执行时间)。没有Quartz的cron表达式那么灵活,但政务系统的定时需求通常是"每隔N秒/分/时执行",固定周期就够了。

为什么不选Quartz? 因为依赖重、配置复杂、内网部署难。JDK自带的Timer+反射,150行代码,任务配置在数据库里,运维人员通过页面管理。和自研IOC的逻辑一样——不是为了造轮子,而是场景不需要那么重的方案。

数据库表驱动Timer vs Quartz对比:

维度QuartzTimer+DB表说明
功能范围Cron表达式、集群调度、持久化固定间隔、单机、DB持久化政务定时任务通常很简单
依赖引入quartz.jar + 依赖链JDK内置Timer零依赖原则
运维方式API或专用管理界面数据库表+页面CRUD运维人员最熟悉的操作
动态调整支持API动态调整改数据库表即时生效效果相同,方式不同
框架集成需额外配置@Trans/@Logger/@monitoring自动生效复用现有AOP管道
适用场景复杂调度(Cron、集群、错过重跑)简单定时(固定间隔、单机)政务场景80%是后者

本章小结

• ThreadLocal绑定上下文(用户、连接、部门),业务层彻底告别HttpServletRequest。

• 连接管理和请求生命周期绑定,finally统一关闭,防止泄漏。

• Timer+反射+数据库表驱动定时任务,不上Quartz,框架注解组合运用。

决策洞察:框架基础设施的设计原则——业务开发人员不应该知道这些基础设施的存在,但应该享受它们带来的能力。

第九章 数据层的基因——不用Hibernate,状态机追踪字段变更

前面九章讲了框架的骨架——IOC、路由、参数解析、AOP、上下文、定时任务。但从这里开始,每一个数据层的设计(MongoDB缓存、MyBatis分页、JDBC游标导出、SM4加解密)都建立在同一个根基上:Dao。

根在2008年——第二章讲过,那个社保三级审核项目逼出了行状态机。当时我用PowerBuilder重写uo_datawindow的update事件,自己遍历行状态拼SQL。后来转到Java,这个思路直接平移过来:每个Dao对象知道自己是什么状态,调一个save()就自动路由到insert/update/delete。Hibernate也能做这件事,但它藏得太多——SQL它生成,缓存它管,脏检查它做。出了问题你连它执行了什么SQL都看不到。政务系统需要的是:每一行SQL我都看得见。

这一章把这个根基讲透。

9.1 问题是什么

政务系统里,前端编辑一条数据,可能是新增、可能是修改、也可能是删除。如果每个操作都单独写一套逻辑,代码量爆炸。特别是批量操作——一个表单里,有的行是新增的,有的行是改过的,有的行被删了。你不可能逐行判断然后调不同的方法。

Hibernate能解决这个问题,但它太重了——Session管理、一级二级缓存、延迟加载、脏检查、HQL语法……政务系统不需要这些。我们需要的是一个极简的ActiveRecord:每个Dao对象知道自己是什么状态,调一个save()就自动路由到insert/update/delete。

9.2 思考过程

能不能用Hibernate? 功能上没问题。但Hibernate生成的SQL你控制不了——它有自己的方言、自己的分页方式、自己的批量策略。第九章(MyBatis分页改造)和第十三章(JDBC游标导出)的方案都没法在Hibernate上做,因为你绕不开它的Session机制。在政务场景下,SQL必须可控——出了问题要能直接看到执行了什么SQL,Hibernate把这一层藏得太深了。

能不能用MyBatis-Plus? MyBatis-Plus的ActiveRecord模式确实封装了insert/update/delete。但它的状态追踪依赖MyBatis的ParameterMap,不够透明。而且我们需要的不是MyBatis-Plus那一套——我们需要一个跟ORM无关的、纯粹的数据对象状态机,它既能跟MyBatis配合,也能跟JDBC直连配合,甚至能跨语言(第二章的C#平移)。

能不能自己写? 核心就是两件事:状态标记(_t)和变更追踪(_o)。两个字段,几百行代码,够用。

9.3 核心设计:_t状态机

private int _t = 0;
// 0=不变,1=新增,3=修改,4=删除

// 修改前的值
HashMap<String, String> _o = new HashMap<>();

就这么两个字段,驱动整个状态追踪:

• _t=0:刚查出来的数据,没变过

• _t=1:前端标记为新增

• _t=3:字段被setItemValue修改过(第一次赋值时自动从0跳到3)

• _t=4:前端标记为删除

_o这个HashMap存的是修改前的值——哪个字段改了,改之前是什么值,都记在这里。这个设计不是凭空想出来的,根在2008年——第二章讲过,那个社保三级审核项目逼出了行状态机。

9.4 save()的自动路由

public void save() throws utilException {
if (this.isInsert())
insert();
else if (this.isModefiy())
update();
else if (this.isDelete())
delete();
}

三个判断方法:

public boolean isModefiy() { return _t == 3; }
public boolean isInsert()  { return _t == 1; }
public boolean isDelete()  { return _t == 4; }

前端传JSON过来,fromJson()解析的时候自动读取_t值。批量数据里每一行有自己的_t,调一次DataStore.save()遍历所有行,每行自动路由到对应操作。开发人员不需要写任何判断逻辑——Dao知道自己是新增还是修改。

9.5 insert/update/delete怎么找到Mapper

关键在泛型反射:

public void insert() throws utilException {
this.verification();
this.check();
ParameterizedType type =
(ParameterizedType) this.getClass().getGenericSuperclass();
Class clazz =         (Class) type.getActualTypeArguments()[0];
DBUtil.SaveDao(clazz, getInsertMethod(), this);
}

BaseDao的泛型参数T是MyBatis的Mapper接口类型。运行时通过反射拿到实际类型,直接调DBUtil.SaveDao。子类只要这样写:

public class MyBusinessDao extends BaseDao {
// 不用重写 insert/update/delete/save
}

框架自动知道用哪个Mapper、调哪个方法。业务开发人员只管定义字段和Mapper SQL,增删改查的路由全自动化。

9.6 setItemValue——触发状态变更

public void setItemValue(String key, Object value) throws ... {
if (_t == 0) {
_t = 3; // 自动标记为修改
}
if (_t == 3) {
if (_o.get(key) == null) {
_o.put(key, getItemStringValue(key));
}
}
Field field = getClass().getDeclaredField(key);
field.setAccessible(true);
field.set(this, TypeUtil.parseObject(field.getType(), value));
field.setAccessible(false);
}

这里有两个细节:

第一,自动触发。 _t==0时自动跳到3(修改),不需要手动设状态。只要调了setItemValue,Dao就知道自己被改过了。

第二,保存旧值。 每个字段第一次被修改时,修改前的值存入_o。这个设计在变更审计场景中非常有用——你可以告诉用户"这个字段从什么值改成了什么值"。

9.7 @ver注解——字段级验证

保存之前自动校验:

@ver(require=true)
private String xm;  // 姓名必填

@ver(reg="^\d{18}$")
private String sfzh;  // 身份证号正则校验

verification()方法遍历所有带@ver注解的字段,空值校验和正则校验都支持。校验失败直接抛异常,框架捕获后返回给前端。另外还有个check()空方法,子类可以重写做业务级校验:

public void check() throws utilException {
// 子类重写——比如"参保日期不能早于出生日期"
}

验证链路:save() → verification()(注解级) → check()(业务级) → insert()/update()。先验证后入库,顺序固定。

9.8 toJson/fromJson——前后端序列化

toJson()把Dao转成JSON字符串传给前端,fromJson()把前端传回的JSON解析成Dao对象。有个细节:_o(修改前值)在toJson()时不传给前端——前端不需要知道修改前的值。但fromJson()解析时能接收前端传回的_t值——前端标记了这行是新增还是修改。

public String getBlock() {
return super.getBlock()
+ "_o|";  // _o不序列化给前端
}

_t不在getBlock()里——所以前端能看到每行的状态,也能修改状态(比如把一行标记为删除)。

9.9 search——查询也封装了

public List search() throws utilException {     ParameterizedType type =         (ParameterizedType) this.getClass().getGenericSuperclass();     Class clazz =
(Class<?>) type.getActualTypeArguments()[0];
if (this.getMaxRow() > 0 && this.getMinRow() >= 0) {
RowBounds page = new RowBounds(
this.getMinRow(),
this.getMaxRow() - this.getMinRow());
return DBUtil.getDao(clazz, getSearchMethod(), this, page);
} else {
return DBUtil.getDao(clazz, getSearchMethod(), this);
}
}

查询条件就是Dao自己的字段值——非空字段作为查询条件。分页也内置了——Dao基类有maxRow和minRow字段,设了值就自动走分页。这和第九章的MyBatis分页改造直接衔接。

9.10 DataStore—— 批量操作的容器

单个Dao只管一行数据。政务系统的表单通常是多行的——一个参保人关联多条缴费记录。DataStore就是一个批量容器:

DataStore ds = new DataStore("t_payment");
RowSet rs = ds.getRowset();

// 新增一行
Row row1 = rs.add();
row1.setItemValue("name", "张三");
row1.setItemValue("amount", 500);
// row1._t 自动为1(新增)

// 再新增一行
Row row2 = rs.add();
row2.setItemValue("name", "李四");
// row2._t 自动为1(新增)

// 批量保存——遍历每行,自动路由
ds.save();  // row1 insert, row2 insert

RowSet内部维护三个区:

• primary区:正常数据(新增和修改的行)

• delete区:被删除的行

• filter区:被过滤掉的行(不在当前视图但仍然存在)

save()遍历primary区的每一行,根据_t状态自动路由。delete区的行走delete操作。一个save()调用,搞定一个表单里所有行的新增、修改和删除。

9.11 决策背后的WHY

为什么不用Hibernate和MyBatis-Plus?10.2已经分析过。这里补充两个设计细节。

为什么_t只有0/1/3/4四个值? 够用。不需要Hibernate那种复杂的状态机(persistent、detached、transient……)。新增、修改、删除、不变——四个状态覆盖所有场景。多一个状态多一份复杂度,政务系统不需要。

一句话:状态机追踪字段变更,save()自动路由增删改。BaseDao六百行代码,没有Hibernate的Session管理、脏检查、延迟加载——就是朴素的反射+状态标记。在政务系统里,够用、可控、好排查。

自研ActiveRecord vs Hibernate对比:

维度Hibernate自研ActiveRecord说明
SQL可控性自动生成,调试困难save()自动路由,SQL可追踪政务排错需要看到每一条SQL
学习曲线HQL/Criteria/Cache体系一个_t状态机+一个save()新人半天上手
性能调优需理解N+1、懒加载、缓存直接看SQL,加索引或加缓存排查链路短
代码量庞大框架核心几百行可审计
事务管理自带,但行为复杂BusinessSession.commit()显式控制政务审计需要精确控制提交时机
适用场景领域模型复杂、关系丰富的业务字段级CRUD为主、SQL需精确控制政务系统80%是后者

这是整个框架数据层的基因。后面第九章的MongoDB缓存通过Dao的level属性路由,第九章的MyBatis分页通过Dao的maxRow/minRow参数传递,第十三章的JDBC游标导出绕开MyBatis但仍然用Dao做数据容器,第十四章的SM4加解密通过Dao的@myCode注解标记——所有数据层的设计都长在这棵树上。

本章小结

• _t状态机(0/1/3/4)+ _o旧值HashMap,两个字段驱动整个Dao的状态追踪。

• save()自动路由增删改,业务开发人员不需要写判断逻辑。

• 泛型反射自动找到MyBatis Mapper,@ver注解做字段级验证。

• 不依赖Hibernate,不依赖MyBatis-Plus,跟ORM无关,跨语言可平移。

决策洞察:数据层的核心不是选哪个ORM,是你对数据的状态有没有追踪。两个字段(_t和_o)驱动的状态机,比任何ORM的脏检查都轻都准。

第十章 数据层优化——MongoDB混合存储与MyBatis分页改造

上一章讲了Dao状态机——框架数据层的基因。这一章讲两个数据层的优化:MongoDB做只读缓存,和Oracle分页的硬解析之坑。它们看起来不相关,但都建立在前一章的Dao根基上。

10.1 场景还原

本章核心:MongoDB做只读缓存层,5%的Dao加缓存,查询从1.2秒降到45毫秒。

系统跑久了,某些查询越来越慢。加索引优化到一定程度后,瓶颈就在关系库本身了。直觉反应是加缓存,但Redis是key-value存储,Oracle是SQL表——两者之间没有自然映射。要让老系统的MyBatis Mapper既查Oracle又查Redis,等于重写整个数据访问层。

MongoDB是Document存储,{字段名: 值}和关系库一行记录天然对应,Dao对象的字段和Document的key一对一映射。而且不需要改业务代码——Dao基类加一个level属性,框架自动判断走MongoDB还是Oracle。

10.2 怎么做的

Dao基类增加缓存配置:

public class Dao implements Cloneable {
// 1:双写模式(MongoDB miss → 查关系库 → 写回MongoDB)
// 2:纯MongoDB + 异步同步队列
// 3:纯关系库(默认,引入不影响)
private int level = 3;
private String upInsField = "";   // 写入字段列表,逗号分隔
private String whereField = "";   // 查询条件字段列表,逗号分隔
private String tableName = "";    // MongoDB的collection名
private String DbName = "";       // MongoDB的库名
}

查询逻辑(level=1的流程):

  1. 先查MongoDB → 有数据?直接返回

  2. MongoDB没有 → 查关系库 → 把结果写回MongoDB → 返回

写入逻辑(level=1的流程):

  1. 先写关系库(保底)

  2. 再写MongoDB(加速后续查询)

level=2的异步同步: 只写MongoDB,往同步队列(sync collection)里插一条记录,异步线程消费队列写回关系库。

Dao与Document的互转: Dao基类提供toDocument()和fromDocument()方法。toDocument(1)用whereField的字段生成查询条件Document,toDocument(2)用upInsField的字段生成写入数据Document。fromDocument()遍历Document的所有key,反射调用Dao的setter方法。

业务代码完全不变:

public class UserDao extends Dao {
public UserDao() {
setLevel(1);            // 开启缓存
setDbName("myapp");     // MongoDB库名
setTableName("t_user"); // collection名
setUpInsField("id,name,age");
setWhereField("id");
}
}

构造函数里配好属性,然后正常调用DBUtil.getDao()和DBUtil.SaveDao(),框架自动路由。

还有一个全局开关isMongoDB——配置文件里不是"1"的话,MongoDB分支完全不进入。这样客户环境没有MongoDB也照样正常。

level属性的路由机制——框架怎么决定走Oracle还是MongoDB。 DBUtil在执行查询和写入时,第一步就是检查Dao的level属性。以查询为例,源码中的判断逻辑是这样的:

if ("1".equals(configUtil.get("isMongoDB")) && params != null && params.length > 0
&& params[0] instanceof Dao) {
Dao dao = (Dao) params[0];
if (dao.getLevel() == 1 || dao.getLevel() == 2) {
String id = calss.getName() + "." + MethodNmae;
IbatisSql ibatisSql = getIbatisSql(null, id, params[0]);
if ("".equals(dao.getWhereField()) || dao.getWhereField() == null) {
dao.setWhereField(ibatisSql.getWheresName());
}
if (dao.getDbName() == null || "".equals(dao.getDbName())) {
throw new utilException("没有配置MongoDB库名!", -9999);
}
if (dao.getTableName() == null || "".equals(dao.getTableName())) {
throw new utilException("没有配置Collection!", -9999);
}
FindIterable list1 = null;
try {
if (ibatisSql.getParameters() != null && ibatisSql.getParameters().size() > 0) {
list1 = mongo.getInstance().find(dao.getDbName(), dao.getTableName(),
dao.toDocument(1));
} else {
list1 = mongo.getInstance().find(dao.getDbName(), dao.getTableName());
}
} catch (Exception e) {
log.warn("MongoDB查询异常,降级到关系库: " + e.getMessage());
}
if (list1 != null) {
MongoCursor mongoCursor = list1.iterator();
result = new ArrayList();
while (mongoCursor.hasNext()) {
Dao dao1 = dao.getClass().newInstance();
result.add(dao1.fromDocument(mongoCursor.next()));
}
}
if (result == null || result.size() < 1) {
if (dao.getLevel() == 1) {
method = obj.getClass().getMethod(MethodNmae, paramTypes);
result = (List) method.invoke(obj, paramValues);
List list = new ArrayList();
for (int i = 0; i < result.size(); i++) {
dao = (Dao) result.get(i);
list.add(dao.toDocument(2));
}
mongo.getInstance().insert(dao.getDbName(), dao.getTableName(), list);
}
}
} else {
method = obj.getClass().getMethod(MethodNmae, paramTypes);
result = (List) method.invoke(obj, paramValues);
}
}

两层判断,三层路由:isMongoDB全局开关决定MongoDB模块是否启用,level属性决定单个Dao走不走MongoDB。全局关了MongoDB,所有Dao都走关系库,和没引入一样。还有一个细节:如果Dao没配置whereField,框架会从MyBatis的SQL解析结果中自动提取查询字段,连whereField都不用手动配。

缓存失效策略——什么时候清MongoDB。 这是混合存储方案中最容易被忽视的问题。数据在MongoDB里,同时也在Oracle里,两边不一致怎么办?

我们的策略是"写即同步"。level=1的Dao,每次写入都同时写关系库和MongoDB——SaveDao()里先调Mapper写关系库,再调mongo.insert()或mongo.update()写MongoDB。不存在"先写关系库再异步同步MongoDB"的延迟窗口。所以对于level=1的Dao,MongoDB和关系库的数据是实时一致的。

对于需要批量刷新的场景(比如年度数据导入),我们在批量操作之前临时把Dao的level设为3(纯关系库),批量完成后再设回1。这样批量操作的性能不受MongoDB双写的影响,操作完成后新的查询会触发MongoDB的miss→查关系库→回填流程,自动恢复缓存。

我们没有设置TTL过期时间。为什么?因为政务系统的数据变更频率低——参保人信息、缴费记录这些数据,改了就同步写MongoDB了,不存在"数据变了但缓存没更新"的情况。如果加了TTL,反而会导致周期性的miss风暴——大量缓存同时过期,瞬间全部打到关系库上。在政务场景下,基于写入同步的缓存一致性比基于TTL的过期策略更可靠。

一个具体的性能对比。 某市社保系统的"参保人员查询"功能,表里有120万行数据。这个查询在参保登记、缴费、待遇核定时都会被调用,是系统里调用频次最高的查询之一。

优化前(纯Oracle):平均响应时间1.2秒。AWR报告显示db file sequential read等待事件占了查询时间的80%——Oracle在做大量随机IO,索引虽然加了,但表太宽(40多个字段),单行IO开销大。

优化后(level=1,MongoDB缓存):首次查询(MongoDB miss,查Oracle后回填)响应时间1.3秒——比纯Oracle还慢了0.1秒,因为多了一次MongoDB的写入。第二次及后续查询(MongoDB hit)响应时间45毫秒。

从1.2秒降到45毫秒,27倍提升。业务代码一行没改——只是在Dao构造函数里加了setLevel(1)。这就是"零侵入"的含义:不是"侵入很小",而是"完全不侵入"。

MongoDB缓存 vs Redis对比(政务场景下):

维度RedisMongoDB混合存储说明
部署要求独立进程,需运维嵌入应用,零运维区县没有Redis运维能力
数据结构KV,需设计序列化文档型,Dao直存直取不用写序列化/反序列化
缓存粒度灵活(字段级)Dao级政务查询通常是整条记录
数据一致性需自行实现同步写入时同步到MongoDBDBUtil管道自动同步
环境依赖部分地市未安装Java内嵌,无外部依赖部署环境决定
适用场景高并发、热数据、团队有Redis经验查询慢、部署环境受限场景不同,选择不同

为什么不用Redis?环境锁是根本原因。 数据模型差异是技术原因。更现实的问题是部署环境不可控——有的现场有Redis,有的没有,有的版本太老。如果选了Redis,所有部署现场都必须安装运维Redis。MongoDB的Document和关系库行天然同构,Dao可以直接映射,基层运维只需装一个MongoDB,后续几乎不需要维护。

这个方案的局限: MongoDB缓存只在单条查询场景下有效——按主键查、按少量条件查。如果是复杂的多表关联、聚合统计,还是得走关系库。另外level=2的异步同步模式下,MongoDB和Oracle之间存在短暂的不一致窗口——如果异步线程还没消费完同步队列,Oracle里就是旧数据。好在政务场景对这种短暂不一致是容忍的,因为真正的兜底是银行对账(第一章讲过)。

10.3 Oracle物理分页的硬解析之坑——改MyBatis源码一行

MyBatis的RowBounds默认是内存分页——先查到内存再取子集,数据量一大直接OOM。通用做法是写Interceptor拦截SQL,在末尾拼接分页语句。但这在Oracle下是隐藏炸弹:Oracle认为每次分页都是不同的SQL模板,每次都做硬解析(Hard Parse),消耗大量CPU和shared pool内存。

正确做法是用绑定变量(?):不管第几页,SQL模板是同一个,Oracle只解析一次。但MyBatis的BoundSql.getParameterMappings()返回的是不可修改的List,直接add()会抛异常。所以我们的方案是:改MyBatis源码一行——把不可修改的List改成普通的ArrayList,然后自己的PaginationInterceptor就能自由操作参数映射了。

拦截器核心逻辑:拦截SQL→绑定变量改写分页语句(?占位)→根据方言生成最终SQL(Oracle用rownum,MSSQL用row_number(),MySQL用limit)。Oracle方言处理了几个复杂情况:有group by或order by时rownum条件必须在外层;有where时追加and,没有时追加where;支持for update语句。最终生成的SQL:

select * from (
select row_.*, rownum rownum_ from (
原SQL
) row_
) where rownum_ <= ? and rownum_ > ?

为什么改源码而不是用PageHelper?那个年代PageHelper还没发布,而且大多数分页插件也是拼接SQL,不解决硬解析问题。还有一个原因:防SQL注入。统一分页走绑定变量,是从框架层面强制保障安全——只要走DBUtil管道,分页参数就不可能被拼进SQL字符串。性能和安全,一个改动同时解决。

代价是MyBatis升级时必须带着这个补丁。好在我们一直用的是MyBatis 3.1.1,十几年没有升级需求。

本章小结

• MongoDB的Document和关系库的行天然同构,Dao对象可以直接映射,老代码零改造就能双写双读。

• 三级level配置:1=双写、2=纯MongoDB+异步、3=纯关系库(默认),渐进式采用。

• 改MyBatis源码一行实现Oracle绑定变量物理分页,性能和安全(防SQL注入)一个改动同时解决。

决策洞察:清楚地知道代价在哪、什么时候会引爆。改别人源码的代价是升级时必须带着补丁,但十几年不升级本身就是一种决策。