看到这样的代码,内心五味杂陈
那天下午,看到了令我终生难忘的代码,那一刻破防了......
🔊 本文记录那些年的 Java 代码轶事
ヾ(•ω•`)🫥 故事还得从半年前数据隔离的那个事情说起......
历史背景
数据隔离
预发,灰度,线上环境共用一个数据库。每一张表有一个 env 字段,环境不同值不同。特别说明:env 字段即环境字段。如下图所示:
🖌️插曲:一开始只有 1 个核心表有 env 字段,其他表均无该字段;有一天预发环境的操作影响到客户线上的数据。为了彻底隔离,剩余的二十几个表均要添加上环境隔离字段。
「当时二十几张表已经大量生产数据,隔离需要做好兼容过渡,保障数据安全。」
其他表历史数据很难做区分,于是新增加的字段 env 初始化 all ,表示预发线上都能访问。以此达到历史数据的兼容。
每一个环境都有一个自己独立标志;从 application.properties 中读该字段;最终到数据库执行的语句如下:
「最拉胯的做法:每一张表涉及到的 DO、Mapper、XML等挨个添加 env 字段。但我指定不能这么干!!!」
具体方案:自定义 mybatis 拦截器进行统一处理。通过这个方案可以解决以下几个问题:
在 mybatis 拦截器中, 通过改写 SQL。新增时填充环境字段值,查询时添加环境字段条件。真正实现改一处即可。考虑历史数据过渡,将 env = ${当前环境}
修改成 env in (${当前环境},'all')
具体实现逻辑如下图所示:
https://github.com/JSQLParser/JSqlParser
思路:自定义拦截器,填充环境参数,修改 sql 语句,下面是部分代码示例:
@Intercepts(
{@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class})}
)
@Component
public class EnvIsolationInterceptor implements Interceptor {
......
@Override
public Object intercept(Invocation invocation) throws Throwable {
......
if (SqlCommandType.INSERT == sqlCommandType) {
try {
// 重写 sql 执行语句,填充环境参数等
insertMethodProcess(invocation, boundSql);
} catch (Exception exception) {
log.error("parser insert sql exception, boundSql is:" + JSON.toJSONString(boundSql), exception);
throw exception;
}
}
return invocation.proceed();
}
}
一气呵成,完美上线。
发展演变
「随着业务发展,出现了以下需求:」
SELECT * FROM ${tableName} WHERE bizId = ${bizId} and env in (?,'all')
这个需求的落地交给了来了快两年的小鲜肉。在开始做之前,他也问我该怎么做;我简单说了一些想法,比如可以跳过环境字段检查,不拼接条件;或者拼接所有条件,这样都能查询;亦或者看一下能不能注解来标志特定方法,你想一想如何实现......
(●ˇ∀ˇ●)年纪大了需要给年轻人机会。
小鲜肉,没多久就实现了。不过有一天下午他遇到了麻烦。他填充的环境字段取出来为 null,看来很久没找到原因,让我帮他看看。(不久前也还教过他 Arthas 如何使用呢,这种问题应该不在话下吧🤔)
大致逻辑:在需要跳过环境条件判断的方法前后做硬编码处理,同环切面逻辑, 一加一删。填充颜色部分为小鲜肉的改造逻辑。
大概逻辑就是:将 env 字段填充所有环境。条件过滤的忽略的目的。
SELECT * FROM ${tableName} WHERE env in ('pre','gray','online','all') AND ${其他条件}
经过排查是因为 API 里面有多处对 threadLoal 进行处理的逻辑,方法之间存在调用。简化举例:A 和 B 方法都是独立的方法, A 在调用 B 的过程,B 结束时把上下文环境字段删除, A 在获取时得到 null。具体如下:
当我看到代码的一瞬间,彻底破防了......
「queryProject 方法里面调用 findProjectWithOutEnv, 在两个方法中,都有填充处理 env 的代码。」
然而,这三行代码,随处可见,在业务代码中遍地开花.......
// 1. 变量保存 oriFilterEnv
String oriFilterEnv = UserHolder.getUser().getFilterEnv();
// 2. 设置值到应用上下文
UserHolder.getUser().setFilterEnv(globalConfigDTO.getAllEnv());
//....... 业务代码 ....
// 3. 结束复原
UserHolder.getUser().setFilterEnv(oriFilterEnv);
改了个遍,很勤劳👍......
「难道真的就只能这么做吗,当然还有......」
「大量魔法值,单行字符超500,方法长度拖几个屏幕也都睁一眼闭一只眼了,但整这一出,还是破防......」
内心涌动😥,我觉得要重构一下。
重构一下
「在 mybatis intercept 中不能直接精准地获取到 service 层的接口调用。只能通过栈帧查询到调用链。」
「同一份代码,在多个环境运行,不管如何,一定要考虑线上数据安全性。」
改造后的使用案例如下,案例说明:「project 表在预发环境校验跳过」 。
@SneakyThrows
@GetMapping("/importSignedUserData")
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
public void importSignedUserData(
......
HttpServletRequest request,
HttpServletResponse response) {
......
}
「在使用的调用入口处添加注解。」
注解代码
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeChainSkipEnvRule {
/**
* 是否跳过环境。默认 true,不推荐设置 false
*
* @return
*/
boolean isKip() default true;
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipEnvList() default {};
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipTableList() default {};
}
🤔那还要不要完善一下,还有什么没有考虑到的点呢?拿起手机看到快12点的那一刻,我还是选择先回家了......
总结思考
这是一个很好参考案例:在应用中既做了数据隔离,也做了数据共享。通过自定义拦截器做数据隔离,通过自定注解切面实现数据共享。
「同样的代码写两次就应该考虑重构了」
「简单梳理,自定义注解 + AOP 的场景」
自定义注解很灵活,应用场景广泛,可以多多挖掘。
在这个只讲业务结果,不讲技术氛围的环境里,突然有一些伤感;身体已经开始吃不消了,好像也过了那个对技术较真死抠的年纪;突然一想,这么做的意义又有多大呢?
-End-
非常感谢您的关注和点赞!每一次的“在看”,都是最大的鼓励与肯定,每一次的“转发”,都是深深的信任与支持。愿每一个点赞和转发的您,都能心想事成,好事连连,幸福满溢!
精彩推荐 1、还在写代码测试并发?太 low了。。。2、为什么程序员的代码不能终生责任制?