惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V
V2EX
博客园 - 叶小钗
WordPress大学
WordPress大学
N
Netflix TechBlog - Medium
M
MIT News - Artificial intelligence
美团技术团队
aimingoo的专栏
aimingoo的专栏
博客园_首页
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Microsoft Security Blog
Microsoft Security Blog
Last Week in AI
Last Week in AI
The GitHub Blog
The GitHub Blog
小众软件
小众软件
T
Tailwind CSS Blog
Martin Fowler
Martin Fowler
B
Blog RSS Feed
月光博客
月光博客
量子位
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Hugging Face - Blog
Hugging Face - Blog
IT之家
IT之家
Y
Y Combinator Blog
B
Blog
MyScale Blog
MyScale Blog

博客园 - buguge

交易成功还要记 3 笔账?我们直接砍到 1 笔—— 网络红包记账的一次自顶向下业务层重构 聚合系统设计:如何为银行代付通道抽象出公共的代付接口能力 如何自定义 MyBatis枚举处理器(EnumTypeHandler):分享从 “同包同名 shadow 覆盖” 到 “官方扩展点” 的重构经历 这儿也改状态,那儿也改状态,连AI都理不清楚了… 不妨试试这个状态机good-practice 业务解耦的经典实践:从订单表解耦开票业务说起(以滴滴网约车开票为例) 从 `int` 到 `Duration`:一个缓存 API 的三次演进教会我的事 最差实践(bad-practice):开发者在方法里直接实例化线程池对象,然后...(应用gg了) 【HttpClient最差实践(bad-practice)】开发者在 http 工具方法中直接实例化 HttpClient,然后…… Crypto、Cipher与Password:Java加密开发的三个核心概念 知识VS技能:如何优雅判空? 20260604SR超时问题排查 推敲见文章:从 `try..catch` 看异常日志打印的正确姿势 #解决问题要彻底# 慢SQL治理完成后,如何防止同类问题“死灰复燃”? 从合同甲方是荒谬的“JD”谈起:软件开发不应遗忘的“常识” 别留小尾巴/尽快剪掉小尾巴:从一次“ABA”字段重命名,谈谈“解决问题要彻底” 常见的OOM错误 ( OutOfMemoryError全类型详解) 开发者暴露了一个无需授权访问的裸接口,我问:如果有人暴力请求怎么办? 【SQL性能优化篇】有了!治理慢SQL“WHERE create_time ORDER BY id”的良药---规避“Using filesort”性能杀手 高效查询商户日终余额:一个SQL的优化实践 Hutool 的 `TimedCache` 到期会自动清理吗? ——————hutool cache的"惰性清理"和"定期清理" Fastjson枚举反序列化:当字符串不是枚举常量名时,会发生什么? fastjson-EnumDeserializer类及源码分析 随笔20260309:我们都是围城里的人 `UnexpectedRollbackException: Transaction rolled back because it has been marked as rollback-only` 异常解析 认识2个单词:goal/target —————— 为什么Maven是 "goal" 而不是 "target"? 聚合系统设计:策略模式(Strategy Pattern)在银行通道对接场景中的应用 这样构建对象,太帅了!—— 阶梯式Builder模式与代码整洁之道 注意!字段数据类型不匹配,这个sql会很慢 还在用ArrayList?用HashSet吧!--性能对比 分页查询还在用create_time去做降序?
分享一次针对hardcode的小重构:不要把 package 名写死在代码里
buguge · 2026-09-09 · via 博客园 - buguge

一、一个看起来没什么问题的实现

最近看到一段 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"));
    }
}

这段代码的作用并不复杂。

image

项目约定,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 接口统一定义在 carpub package 及其子 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.,不要硬编码!

更重要的是,不要在看到不合理代码时选择无视。

发现问题,主动思考,积极寻找更好的解决方案,然后把它改掉。

这,也许也是一个程序员应有的技术态度。