分享一次针对hardcode的小重构:不要把 package 名写死在代码里
一、一个看起来没什么问题的实现
最近看到一段 Spring MVC 的配置代码:
@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix(
"/carpub",
c -> c.getPackage()
.getName()
.startsWith("com.emax.entbase.modules.carpub"));
}
}
这段代码的作用并不复杂。

项目约定,carpub package 及其子 package 下的 Controller,都属于 carpub 业务模块。系统在注册这些 Controller 的请求路径时,统一为它们增加 /carpub 前缀。
例如下面web方法,最终对外暴露的Web请求路径是:/carpub/bill。
package com.emax.entbase.modules.carpub.settlement;
@RestController
@RequestMapping("/bill")
public class BillController {
}
从功能上看,这段代码没有问题。
能运行,也确实达到了目的。
那么,如果你来 review 这段代码,你觉得它还有什么问题吗?
(先别急着往下看~~~~)
我的判断是:这里存在一个不太合理的实现。
问题就在这个 package 名:
"com.emax.entbase.modules.carpub"
它被硬编码在了程序中。
二、硬编码的问题是什么?
所谓硬编码,就是把一个可能发生变化的信息,以普通字符串的方式直接写进程序。
这里的 package 名恰好就是这样一种信息。
假设后来我们对项目结构进行调整,将 package 修改为:
com.emaxcard.entbase.modules.carpub
Controller 本身可以通过 IDE 的重构功能完成迁移,相关的 import 也会自动修改。
但是上面web配置类中的"com.emax.entbase.modules.carpub"这个字符串不会跟着改。
因为在 Java 看来,它只是一个普普通通的字符串。
编译器不知道它代表一个 package,IDE 也未必知道它应该跟随 package 一起变化。
更麻烦的是,这段代码不会因此编译失败。
项目可以正常编译、正常启动,甚至大部分功能看起来都没有异常。但是,/carpub 前缀已经悄悄失效了。
原来的接口:
/carpub/bill
可能突然变成:
/bill
这类问题很讨厌。
它不像语法错误那样直接告诉你“这里写错了”,而是在程序运行之后,用一个接口 404 来提醒你:
hey,你有一段字符串忘记改了哦~~
actually,真正麻烦的往往不是那些不能编译的代码,而是那些可以正常编译、正常启动,却在运行时悄悄改变行为的代码。
把字符串抽成常量,可以吗?
看到硬编码,很多人的第一反应是抽一个常量:
private static final String CARPUB_BASE_PACKAGE =
"com.emax.entbase.modules.carpub";
这当然比把字符串直接散落在方法里好一点。
但是,它并没有真正解决问题。
package 名仍然是字符串。
package 发生变化时,编译器仍然不会提醒我们,IDE 的 package 重构也仍然可能遗漏它。
我们只是把硬编码从方法里搬到了常量里。
问题换了个位置,但问题还是那个问题。
thinking....
既然 package 在 Java 中本来就是一种能够被编译器和 IDE 识别的结构,为什么还要把它降级成一个字符串来使用呢?
三、解法一:使用 package marker
我们可以在 carpub 根 package 中定义一个定位接口:
package com.emax.entbase.modules.carpub;
/**
* carpub Web API 所在包的定位标记。
*/
public interface CarPubWebApi {
}
这里的 CarPubWebApi 不负责定义业务方法,也不需要让所有 Controller 实现它。
它只有一个作用:
以 Java 类型的方式,定位 carpub 模块所在的 package。
然后修改 MVC 配置:
// 需要引入
import org.springframework.web.method.HandlerTypePredicate;
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix(
"/carpub",
HandlerTypePredicate.forBasePackageClass(CarPubWebApi.class));
}
HandlerTypePredicate.forBasePackageClass(...) 会取得 CarPubWebApi 所在的 package,并匹配这个 package及其所有子 package 下的 Controller。
改造以后,我们不再需要通过hardcode获取 package 名,它被替换成了一个更加明确的表达:HandlerTypePredicate.forBasePackageClass(CarPubWebApi.class)
代码表达的意思也从:
判断 Controller 的 package 字符串是否以某个字符串开头。
变成了:
为
CarPubWebApi所在业务 package 中的 Controller 增加路径前缀。
后者显然更接近我们的设计意图。
四、解法二:使用业务标记注解
除了 package marker,还可以定义一个业务标记注解:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CarPubApi {
}
然后在属于 carpub 模块的 Controller 上增加这个注解:
@CarPubApi
@RestController
@RequestMapping("/bill")
public class BillController {
}
MVC 配置则可以写成:
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix(
"/carpub",
HandlerTypePredicate.forAnnotation(CarPubApi.class));
}
这个方案也彻底去掉了 package 名硬编码。
而且,它不再依赖 Controller 位于哪个 package。
即使以后将 BillController 从:
com.emax.entbase.modules.carpub.settlement
移动到:
com.emaxcard.web.controller
只要 @CarPubApi 还在,最终请求地址仍然会带上 /carpub 前缀。
从职责表达上看,注解方案也很直接:
@CarPubApi
它明确告诉读者:
这是一个 carpub Web API。
但是,这个方案也有一个现实成本。
我们需要修改所有相关的 Controller,逐个增加 @CarPubApi。以后新增 carpub Controller 时,也必须记得增加这个注解。
如果漏加了,程序通常仍然可以正常编译和启动,但接口不会获得 /carpub 前缀。
也就是说,注解方案把约束从:
Controller 必须放在 carpub package 下。
变成了:
Controller 必须添加
@CarPubApi。
它消除了对 package 结构的依赖,但同时引入了一项新的注解约定。
如果项目允许 carpub Controller 分散在不同的 package 中,那么使用这个注解的方式更合适。
考虑到我们当前的项目已经有一个清晰约定:
carpub 相关的 Web 接口统一定义在
carpubpackage 及其子 package 中。
既然已经有这项 package 约定,就没有必要再给每个 Controller 增加相同的注解。
是的,没有必要为了“看起来更灵活”,再引入一套当前并不需要的规则。适合的方案,才是更好的方案。
五、这次小重构带来了什么?
改造前:
configurer.addPathPrefix(
"/carpub",
c -> c.getPackage()
.getName()
.startsWith("com.emax.entbase.modules.carpub"));
改造后:
configurer.addPathPrefix(
"/carpub",
HandlerTypePredicate.forBasePackageClass(CarPubWebApi.class));
代码量并没有减少多少,但程序的可靠性和可维护性已经发生了变化。
第一,去掉了 package 名硬编码。
package 不再以一个没有类型约束的字符串存在,而是通过 CarPubWebApi.class 定位。
第二,可以得到编译器和 IDE 的帮助。
CarPubWebApi 被删除、移动或者重命名时,Java 类型引用能够被识别。我们不再完全依靠开发人员记住并手工搜索某个字符串。
第三,业务意图更加明确。
看到:
HandlerTypePredicate.forBasePackageClass(CarPubWebApi.class)
我们立即就能知道,这里是在匹配 carpub 业务模块根 package 下的 Controller。
第四,不需要修改现有的所有 Controller。
我们不需要给每个 Controller 增加注解,也不需要让它们实现某个接口。只需要保留项目现有的 package 约定即可。
第五,避免了 startsWith 带来的模糊匹配。
例如:
com.emax.entbase.modules.carpub2
从字符串上看,它同样以com.emax.entbase.modules.carpub开头。
手写 startsWith 存在误匹配类似 package 的可能,而 forBasePackageClass 表达的是明确的基础 package 及其子 package,语义更加准确。
六、看到不好的实现,要主动寻找更好的方案
这次改动很小。
只是去掉了一个 package 字符串,增加了一个空接口,并使用了 Spring 官方提供的 HandlerTypePredicate。
但我认为,这类小重构很有价值。
很多不合理的代码,一开始都不会造成严重故障。
它们通常能够运行,也能完成需求,所以很容易被一句“先这样吧”保留下来。
一个硬编码似乎没什么。
两个硬编码似乎也没什么。
但是,当这种实现不断增加,系统就会逐渐充满只有开发人员自己记得维护的隐式约定。
e.g.:package 修改时要记得搜索字符串。 or 类名修改时要记得修改配置。 or 业务类型增加时要记得补充 if/else。 or 这些“记得”,本质上都是程序没有替我们承担的责任。etc.
所以,当我们看到这种不好的实现时,不要只是觉得:
好像不太优雅,不过能用就行。
应该再向前走一步:
Java 或者框架本身,有没有提供更加合理的表达方式?
这一次,Spring 已经提供了答案:
HandlerTypePredicate.forBasePackageClass(CarPubWebApi.class)
当然,我们也找到了另一种答案:
HandlerTypePredicate.forAnnotation(CarPubApi.class)
然后再结合项目已有的 package 约定,选择更适合当前项目的前一种方案。
这,才是一个相对完整的技术判断过程:
发现硬编码
→ 意识到维护风险
→ 主动寻找替代方案
→ 对比不同方案
→ 结合项目约定做出选择
优秀的程序设计,并不一定都是规模很大的架构调整。
很多时候,它只是拒绝随手写下一个字符串,主动寻找一个类型更明确、意图更清楚、能够得到编译器帮助的实现。
hard-coded strings are a bad idea.,不要硬编码!
更重要的是,不要在看到不合理代码时选择无视。
发现问题,主动思考,积极寻找更好的解决方案,然后把它改掉。
这,也许也是一个程序员应有的技术态度。
当看到一些不好的代码时,会发现我还算优秀;当看到优秀的代码时,也才意识到持续学习的重要!--buguge
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/buguge/p/22911660
浙公网安备 33010602011771号