why技术 2023-12-07 08:18 发表于四川
你好呀,我是歪歪。
在掘金看到一篇文章,让我产生了想要分享的欲望。
讲述的是面对同一个需求,一个工作经验不到两年的小鲜肉和一个工作六年的老司机给出的两个不同技术方案的实现落地。
先是小鲜肉写了一版实现,然后老司机在审查代码的时候觉得应该有更优雅的落地解决方案,于是又按照自己的思路重构了一版。
关于这个事情,歪师傅也有一点自己的看法,但是需要先把文章看完可能才能 get 到我的点,所以放在后面,我们先看文章吧。
链接:https://juejin.cn/post/7294844864020430902
作者:uzong
一、历史背景
那天下午,看到了令我终生难忘的代码,那一刻破防了......
故事还得从半年前数据隔离的那个事情说起......
1.1 数据隔离
预发,灰度,线上环境共用一个数据库。
每一张表有一个 env 字段,环境不同值不同。
特别说明:env 字段即环境字段。
如下图所示:
1.2 隔离之前
插曲:一开始只有 1 个核心表有 env 字段,其他表均无该字段;有一天预发环境的操作影响到客户线上的数据。为了彻底隔离,剩余的二十几个表均要添加上环境隔离字段。
当时二十几张表已经大量生产数据,隔离需要做好兼容过渡,保障数据安全。
1.3 隔离改造
其他表历史数据很难做区分,于是新增加的字段 env 初始化 all ,表示预发线上都能访问。以此达到历史数据的兼容。
每一个环境都有一个自己独立标志;从 application.properties 中读该字段;最终到数据库执行的语句如下:
SELECT XXX FROM tableName WHERE env = ${环境字段值} and ${condition}
1.4 隔离方案
最拉胯的做法:每一张表涉及到的 DO、Mapper、XML等挨个添加 env 字段。但我指定不能这么干!!!
具体方案:自定义 mybatis 拦截器进行统一处理。
通过这个方案可以解决以下几个问题:
-
业务代码不用修改,包括 DO、Mapper、XML等。只修改 mybatis 拦截的逻辑。
-
挨个添加补充字段,工程量很多,出错概率极高
-
后续扩展容易
1.5 最终落地
在 mybatis 拦截器中, 通过改写 SQL。新增时填充环境字段值,查询时添加环境字段条件,真正实现改一处即可。
考虑历史数据过渡,将 env = ${当前环境} 修改成 env in (${当前环境},'all')
SELECT xxx FROM ${tableName} WHERE env in (${当前环境},'all') AND ${其他条件}
具体实现逻辑如下图所示:
1.其中 env 字段是从 application.properties 配置获取,全局唯一,只要环境不同,env 值不同
借助 JSqlParser 开源工具,改写 sql 语句,修改重新填充、查询拼接条件即可。
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();
}
}
一气呵成,完美上线。
二、发展演变
2.1 业务需求
随着业务发展,出现了以下需求:
1.上下游合作,我们的 PRC 接口在匹配环境上与他们有差异,需要改造
SELECT * FROM ${tableName} WHERE bizId = ${bizId} and env in (?,'all')
2.有一些环境的数据相互相共享,比如预发和灰度等
3.开发人员的部分后面,希望在预发能纠正线上数据等
2.2 初步沟通
这个需求的落地交给了来了快两年的小鲜肉。
在开始做之前,他也问我该怎么做。我简单说了一些想法,比如可以跳过环境字段检查,不拼接条件;或者拼接所有条件,这样都能查询;亦或者看一下能不能注解来标志特定方法,你想一想如何实现......
年纪大了需要给年轻人机会。
2.3 勤劳能干
小鲜肉,没多久就实现了。
不过有一天下午他遇到了麻烦。他填充的环境字段取出来为 null,看来很久没找到原因,让我帮他看看。
但是不久前也还教过他 Arthas 如何使用呢,这种问题应该不在话下吧?
2.4 具体实现
大致逻辑:在需要跳过环境条件判断的方法前后做硬编码处理,同环切面逻辑, 一加一删。
填充颜色部分为小鲜肉的改造逻辑。
大概逻辑就是:将 env 字段填充所有环境。条件过滤的忽略的目的。
SELECT * FROM ${tableName} WHERE env in ('pre','gray','online','all') AND ${其他条件}
2.5 错误原因
经过排查是因为 API 里面有多处对 threadLoal 进行处理的逻辑,方法之间存在调用。
简化举例:A 和 B 方法都是独立的方法, A 在调用 B 的过程,B 结束时把上下文环境字段删除, A 在获取时得到 null。
具体如下:
2.6 五味杂陈
当我看到代码的一瞬间,彻底破防了......
queryProject 方法里面调用 findProjectWithOutEnv, 在两个方法中,都有填充处理 env 的代码。
2.7 遍地开花
然而,这三行代码,随处可见,在业务代码中遍地开花.......
// 1. 变量保存 oriFilterEnv
String oriFilterEnv = UserHolder.getUser().getFilterEnv();
// 2. 设置值到应用上下文
UserHolder.getUser().setFilterEnv(globalConfigDTO.getAllEnv());
//....... 业务代码 ....
// 3. 结束复原
UserHolder.getUser().setFilterEnv(oriFilterEnv);
改了个遍,很勤劳......
2.8 灵魂开问
难道真的就只能这么做吗,当然还有......
-
开闭原则符合了吗
-
改漏了应该办呢
-
其他人遇到跳过的检查的场景也加这样的代码吗
-
业务代码和功能代码分离了吗
-
填充到应用上下文对象 user 合适吗
-
.......
大量魔法值,单行字符超 500,方法长度拖几个屏幕也都睁一眼闭一只眼了,但整这一出,还是破防......
内心涌动,我觉得要重构一下。
三、重构一下
3.1 困难之处
在 mybatis intercept 中不能直接精准地获取到 service 层的接口调用。只能通过栈帧查询到调用链。
3.2 问题列表
-
尽量不要修改已有方法,保证不影响原有逻辑;
-
尽量不要在业务方法中修改功能代码;关注点分离;
-
尽量最小改动,修改一处即可实现逻辑;
-
改造后复用能力,而不是依葫芦画瓢地添加这种代码
3.3 实现分析
-
用独立的 ThreadLocal,不与当前用户信息上下文混合使用
-
注解+AOP,通过注解参数解析,达到目标功能
-
对于方法之间的调用或者循环调用,要考虑优化
同一份代码,在多个环境运行,不管如何,一定要考虑线上数据安全性。
3.4 使用案例
采用了自定义注解的方式:@InvokeChainSkipEnvRule
其使用案例如下:
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
案例说明:project 表在预发环境校验跳过。
使用的方式就是在调用入口处添加该注解:
@SneakyThrows
@GetMapping("/importSignedUserData")
@InvokeChainSkipEnvRule(skipEnvList = {"pre"}, skipTableList = {"project"})
public void importSignedUserData(
......
HttpServletRequest request,
HttpServletResponse response) {
......
}
3.5 具体实现
-
1.方法上标记注解, 注解参数定义规则
-
2.切面读取方法上面的注解规则,并传递到应用上下文
-
3.拦截器从应用上下文读取规则进行规则判断
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface InvokeChainSkipEnvRule {
/**
* 是否跳过环境。 默认 true,不推荐设置 false
*
* @return
*/
boolean isKip() default true;
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipEnvList() default {};
/**
* 赋值则判断规则,否则不判断
*
* @return
*/
String[] skipTableList() default {};
}
3.6 不足之处
-
1.整个链路上的这个表操作都会跳过,颗粒度还是比较粗
-
2.注解只能在入口处使用,公共方法调用尽量避免
那还要不要完善一下,还有什么没有考虑到的点呢?
拿起手机看到快 12 点的那一刻,我还是选择先回家了......
四、总结思考
4.1 隔离总结
这是一个很好参考案例:在应用中既做了数据隔离,也做了数据共享。通过自定义拦截器做数据隔离,通过自定注解切面实现数据共享。
4.2 编码总结
同样的代码写两次就应该考虑重构了
-
尽量修改一个地方,不要写这种边边角角的代码
-
善用自定义注解,解决这种通用逻辑
-
可以妥协,但是要有底线
-
......
4.3 场景总结
简单梳理,自定义注解 + AOP 的场景
自定义注解很灵活,应用场景广泛,可以多多挖掘。
4.4 反思总结
-
如果一开始就做好技术方案或者直接使用不同的数据库
-
是否可以拒绝那个所谓的需求
-
先有设计再有编码,别瞎搞
4.5 最后感想
在这个只讲业务结果,不讲技术氛围的环境里,突然有一些伤感。身体已经开始吃不消了,好像也过了那个对技术较真死抠的年纪。
突然一想,这么做的意义又有多大呢?
歪师傅说
以上就是这个作者的文章内容。
首先,歪师傅非常赞同作者采用“自定义注解 + AOP”的这个方案来落地这个需求,这样做最大的一个好处就是只要你该打注解的地方都打上了,那么处理的逻辑就全部收敛到了切面里面去,而不是散落在业务代码的各个地方。
如果你是一个有一点经验的程序员,你就知道“逻辑收敛在一处”是一个多么美妙的描述。
然后,歪师傅来回答作者提出的“这么做的意义又有多大呢”这个问题。
往大了说,这是一种“传承”。
对应两年经验的那位同事来说,面对同一个需求他看到了另外一种截然不同的落地方案,而且从最后的效果来看,明显是比他最开始写的那一版要好的。
这就是一个非常好的学习机会,而且学习的效果比直接告诉他应该用什么方案去做好了不知道多少倍。
我们这一行不像是一些代代相传的老手艺,需要有人手把手的去教,需要一代一代的学。但是这个行业里面有前人总结出来的一些好的东西,应该传递出去。
我把我觉得好的东西写出来放在这里,你看了,学过去了,这就一种“传承”。
往小了说,这是一种“底气”。
更好的落地方案意味着更加稳定的运行表现。
歪师傅曾经有一段时间就处于对线上运行的系统完全无底气的状态,因为逻辑太散了,每来一个需求就会立一个烟囱,而且烟囱之间还不是完全独立,相互交错,盘根错节。
每天就处于一种知道可能有问题,但是不知道具体是什么问题的状态。
就像是这篇文章中写的:修改逻辑散落在业务代码的各处,如果改漏了应该办呢?
对于线上运行的系统没有底气,是一件非常可怕的事情。
所以,采用新的方案,就是让自己重拾底气的过程。
歪师傅后来就特意申请相应的资源,梳理了没有底气的部分,也是大刀阔斧的改了一遍,现在特别有底气。
面对业务同事的发文,歪师傅的内心活动从“肯定是我们哪个地方又搞错了”变成了“肯定是他们哪个地方配置没对”。
这就是底气。
希望你也能有所传承和十足的底气。
当然了,以上也只是代表歪师傅的个人观点而已,如果你有自己不一样的看法,也可以表达并坚持。
这个问题见仁见智,比如我在文章的评论区也看到了这样的评论:
美美与共,和而不同。
peace & love
skr
·············· END ··············