做后端开发的同行应该都有过这种经历:系统里到处散落着加解密相关的代码,订单号转码一个写法,接口参数加密一个写法,数据库里存的敏感字段用的又是另一套逻辑。我这次要讲的,就是我把这类散落逻辑收拢成一个统一工具类的全过程,围绕 Base64 解码编码、DES 加密解密,以及最终对外暴露的 PasswordUtils 解加密工具类,把这几个东西彻底理清楚、做成一套可测试、可替换、能平滑迁移的代码。
这篇文章适合几类人看:项目里到处复制粘贴加解密代码、想统一收口却不知道怎么设计的开发者;接手遗留系统、看到老数据的密文却不知道当时用什么参数加密的维护者;以及刚接触对称加密和 Base64,想系统搞懂原理的新手。不管你是哪种,读完至少能直接照着把工具类写出来,还能避开我在实际项目中踩过的那些坑。
1. 从一次代码评审说起:为什么需要统一加解密工具
1.1 项目里那些散落的代码
我接手的这个内部系统不算大,但是跑的业务时间长了,代码里积累了不少"历史包袱"。做一次代码评审时,我随手搜了一下加密相关的关键字,结果发现光 DES 就有三四个实现版本,分布在不同模块里:
- 有的直接用
DES/ECB/PKCS5Padding,有的用DES/CBC/PKCS5Padding; - 有的加密结果用 Base64 输出,有的转成十六进制字符串,有的干脆把
byte[]直接存储到数据库; - 密钥写在不同的常量类里,同一个系统的不同模块居然用的是不同的工作密钥;
- 中文文本加密时,有的地方用
getBytes()不指定字符集,依赖服务器默认编码,结果在 Linux 上好好的,迁到 Windows 上加密结果就变了。
这种状态最直接的后果就是:新同学写代码时不知道该用哪一套,只能照着最近的调用抄一份,于是又多出一份变体;出问题的时候,排查的人得把五六处加密代码全部看一遍才能确定当时用的是哪种模式和填充方式。更有一次,两个服务之间做接口对接,A 服务用 CBC 加密,B 服务用 ECB 解密,联调了好几个小时才定位到模式不一致。
1.2 我的设计目标
所以我在这次整改里给自己定了几个明确的目标:
- 对外只暴露一个入口类
PasswordUtils,把 Base64 和 DES 的复杂度全部封装在内部; - 所有编码统一走 Base64,不使用十六进制、不直接存
byte[]; - 所有字符集统一 UTF-8,杜绝平台差异;
- 工作密钥从外部注入,不散落在业务代码里;
- 把加密模式、填充方式这些参数固定在一个地方,不允许调用方自行指定;
- 必须有对应的单元测试,并且固化一批"锚点密文",防止以后有人改动算法细节导致老数据解不开。
这几个目标说起来简单,落地的时候牵扯到的技术细节可不少。下面我从 Base64 和 DES 的原理开始,把每一步都拆开讲清楚。
2. Base64 是什么:编码、变体与误用
2.1 Base64 的原理其实很简单
很多初学者容易把 Base64 当成加密,这其实是个误区。Base64 是一种编码方式,它的作用是把任意二进制数据转换成由 64 个可打印字符组成的文本。为什么需要它?因为二进制数据在传输和存储过程中很容易出问题:JSON 里不能直接放byte[],URL 参数里不能出现不可见字符,数据库文本字段可能不兼容二进制内容,日志打印二进制会变成一堆乱码。把这些二进制数据转换成纯文本,就能安全地放进各种容器里。
Base64 的转换规则是:把原始字节流按每 3 个字节一组(也就是 24 个 bit),拆成 4 个 6 bit 的单元,每个 6 bit 单元对应A-Z、a-z、0-9、+、/这 64 个字符中的一个。如果原始数据字节数不是 3 的倍数,就补零并在末尾加=号表示补位。比如字符串Hello一共 5 个字节,转成 Base64 是SGVsbG8=,这个=就是用来告诉解码端最后一组只有 2 个字节。
在 Java 8 之前,标准库里的 Base64 编码器写起来又长又绕,很多人干脆用第三方库或者自己手写。Java 8 之后官方提供了java.util.Base64,这才是推荐做法。自己手写 Base64 纯属给自己挖坑,性能和正确性都很难保证。
2.2 不同变体怎么选
Base64 不是只有一种。标准编码里包含+和/两个字符,这俩在 URL 里会有问题:+会被解析成空格,/会干扰路径分隔。所以互联网场景下要用 URL 安全的变体,把+换成-、把/换成_,并且通常会去掉末尾的=,因为=在部分框架里也会引起解析异常。
还有一种 MIME 变体,每 76 个字符插入一个换行,主要用在邮件附件场景,企业内部服务间调用一般用不到,这里就不展开。
我的建议是:对外传输、链接参数场景一律用 URL Safe 变体;数据库存储、配置文件存储用标准变体即可,但工具类里两种都可以提供,由上层决定。
2.3 工具类实现:Base64Utils
基于上面的分析,我先写了一个Base64Utils,把编码解码统一封装起来。这段代码看起来平淡无奇,但至少解决了三件事:统一字符集为 UTF-8、统一入口、提供 URL Safe 变体。
import java.nio.charset.StandardCharsets; import java.util.Base64; public final class Base64Utils { private Base64Utils() { } public static String encode(String plainText) { if (plainText == null) { return null; } return Base64.getEncoder() .encodeToString(plainText.getBytes(StandardCharsets.UTF_8)); } public static String decode(String base64Text) { if (base64Text == null) { return null; } byte[] bytes = Base64.getDecoder().decode(base64Text); return new String(bytes, StandardCharsets.UTF_8); } public static String encodeUrlSafe(String plainText) { if (plainText == null) { return null; } return Base64.getUrlEncoder().withoutPadding() .encodeToString(plainText.getBytes(StandardCharsets.UTF_8)); } public static String decodeUrlSafe(String base64Text) { if (base64Text == null) { return null; } byte[] bytes = Base64.getUrlDecoder().decode(base64Text); return new String(bytes, StandardCharsets.UTF_8); } }这里有一个值得注意的细节:getUrlDecoder()能容忍没有 padding 的输入,所以encodeUrlSafe去掉=之后,decodeUrlSafe依然可以正确解码。如果将来对接方传来的是带=的 URL Safe 内容,也不会有问题。
注意:Base64 编码不是加密,任何人拿到 Base64 字符串都可以直接解码得到原文。它的作用是让二进制数据"变成文本",不是保护数据机密性。
3. DES 加解密:参数、模式与填充
3.1 对称加密和 DES 的基本规则
DES 是对称加密算法,加密和解密使用同一个密钥。它是 1977 年被采纳为联邦标准的算法,分组大小 64 bit(8 字节),密钥也是 64 bit(8 字节),但其中 8 位是奇偶校验位,真正有效的是 56 位。也正因为有效密钥只有 56 位,DES 在现代计算能力下已经可以被暴力破解,早就不推荐用于高安全场景了。但现实是,很多遗留系统还在使用 DES,或者外部系统对接时只支持 DES。既然项目定了用 DES,我们能做的就是把它封装得正确、稳定、可维护,同时规划好迁移路径。
在 Java 里使用 DES 有几个关键参数必须搞清楚:
- 算法名称:
DES; - 转换字符串:
DES/CBC/PKCS5Padding,这个字符串决定算法、模式、填充三件事; - 密钥:必须是 8 字节的字节数组;
- CBC 模式下还需要一个 8 字节的初始向量 IV。
密钥这块特别容易踩坑。SecretKeySpec构造时不会立刻校验密钥长度,非法长度的密钥要等到Cipher.init()甚至Cipher.doFinal()才报错。所以工具类里应该在入口先做一次校验,把问题提前暴露出来。有些同学直接用超过 8 字节的字符串当密钥,结果getBytes()之后被SecretKeySpec默默截断,只在特定的输入下才偶发异常,这种问题排查起来非常痛苦。
3.2 CBC 与 ECB 怎么选
ECB(电子密码本模式)是最简单的一种模式:每个 8 字节明文块独立加密成 8 字节密文块。问题在于,相同的明文块永远产生相同的密文块,这会导致加密结果泄露结构信息。一个经典的例子是:如果明文是一张图片,ECB 加密后图片的轮廓特征仍然肉眼可见。所以除非极特殊的兼容性需求,ECB 都不是推荐选项。
CBC(密码块链接模式)引入了初始向量 IV,每个明文块先与上一个密文块做异或运算,然后再加密。这样做的好处是:相同的明文块在不同的位置会产生不同的密文块,模式的泄露被弱化。第一个明文块没有上一个密文块可用,就和 IV 异或。所以 CBC 模式里 IV 的选择也很关键,固定 IV 的缺陷后面会展开说。
Java 里 CBC 模式的 IV 必须是 8 字节,常见的错误是:
- IV 字符串长度不是 8 字节;
- 加密和解密用了不同的 IV;
- 不同服务实例配置的 IV 不一致。
3.3 填充方式 PKCS5Padding
DES 是分组加密,每次处理 8 字节,但明文长度不一定是 8 的倍数。这时候就需要填充。PKCS5Padding 的规则是:缺 n 个字节就补 n 个字节,每个字节的值都是 n。比如明文最后一块只有 5 字节,缺 3 个字节,那就补 3 个0x03;如果明文恰好是 8 的倍数,也要补整整 8 个字节的0x08,这是为了让解密端能明确区分"原始数据末尾本身是 0 还是填充值"。
Java 里的DES/CBC/PKCS5Padding实际上对应的是 PKCS7,只是 Java 沿用了 PKCS5 这个名字。真正实现的时候,填充和去填充都由Cipher自动完成,我们只需要保证指定转换字符串即可。
如果选了NoPadding,调用方必须自己保证明文长度是 8 字节的倍数,否则doFinal会直接抛异常。这个选项只适用于二进制数据和特殊协议,普通文本场景没有必要用。
4. PasswordUtils 工具类的完整落地
4.1 分层结构
我把整个工具类拆成三个层级,职责各不重叠:
Base64Utils:负责编码层,把加密后的字节数组转成字符串,或者把字符串解码成字节数组;DesUtils:负责算法层,处理 DES 的密钥规格、IV、Cipher 生命周期,输入输出统一为字符串;PasswordUtils:负责外观层,是业务代码唯一依赖的入口,内部持有工作密钥,提供encrypt/decrypt两个方法。
为什么不让业务代码直接调DesUtils?因为一旦DesUtils散落到业务代码里,就会出现前面说的那种问题:今天有人传工作密钥 A,明天有人传工作密钥 B,后天有人改用 ECB。PasswordUtils作为门面,把工作密钥的注入限制在一处,业务侧只需要说"我要加密这个字符串"和"我要解密这个字符串",不用关心底层算法细节。
4.2 核心实现
先看DesUtils。这里我选择 CBC + PKCS5Padding,密钥和 IV 都从方法入参传入。IV 实际项目中一般从配置读取,这里给出一个默认值仅作为演示。
import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; public final class DesUtils { public static final String TRANSFORMATION = "DES/CBC/PKCS5Padding"; private static final String ALGORITHM = "DES"; private static final int KEY_LENGTH = 8; private DesUtils() { } public static String encrypt(String plainText, String key, String iv) { if (plainText == null) { throw new IllegalArgumentException("明文不能为空"); } validateKeyAndIv(key, iv); try { SecretKeySpec keySpec = new SecretKeySpec( key.getBytes(StandardCharsets.UTF_8), ALGORITHM); IvParameterSpec ivSpec = new IvParameterSpec( iv.getBytes(StandardCharsets.UTF_8)); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal( plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } catch (Exception e) { throw new IllegalStateException("DES 加密失败", e); } } public static String decrypt(String encryptedText, String key, String iv) { if (encryptedText == null) { throw new IllegalArgumentException("密文不能为空"); } validateKeyAndIv(key, iv); try { SecretKeySpec keySpec = new SecretKeySpec( key.getBytes(StandardCharsets.UTF_8), ALGORITHM); IvParameterSpec ivSpec = new IvParameterSpec( iv.getBytes(StandardCharsets.UTF_8)); Cipher cipher = Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted = cipher.doFinal( Base64.getDecoder().decode(encryptedText)); return new String(decrypted, StandardCharsets.UTF_8); } catch (Exception e) { throw new IllegalStateException("DES 解密失败", e); } } private static void validateKeyAndIv(String key, String iv) { if (key == null || key.getBytes(StandardCharsets.UTF_8).length != KEY_LENGTH) { throw new IllegalArgumentException("DES 工作密钥必须是 8 字节"); } if (iv == null || iv.getBytes(StandardCharsets.UTF_8).length != KEY_LENGTH) { throw new IllegalArgumentException("DES 初始向量 IV 必须是 8 字节"); } } }再写一个外观层PasswordUtils。这里我采用了构造器注入工作密钥的方式,避免每次调用都传入密钥。关于密钥从哪里来,下一小节单独讲。
import java.nio.charset.StandardCharsets; public final class PasswordUtils { private final String workKey; private final String iv; public PasswordUtils(String workKey, String iv) { this.workKey = workKey; this.iv = iv; } public String encrypt(String plainText) { return DesUtils.encrypt(plainText, workKey, iv); } public String decrypt(String encryptedText) { return DesUtils.decrypt(encryptedText, workKey, iv); } public static String encodeBase64(String plainText) { return Base64Utils.encode(plainText); } public static String decodeBase64(String encodedText) { return Base64Utils.decode(encodedText); } }这样一个简单的PasswordUtils就完成了。业务代码里只需要:
PasswordUtils passwordUtils = new PasswordUtils("k3y!2024", "initvect"); String cipher = passwordUtils.encrypt("需要保护的敏感信息"); String plain = passwordUtils.decrypt(cipher);initvect正好 8 字节,k3y!2024也正好 8 字节,两个都满足 DES 的长度要求。
4.3 工作密钥管理
密钥的存储和轮换,是这套工具类落地时最容易被低估的部分。我见过有人在代码仓库里明文提交密钥,后来仓库权限泄露,等于密钥也跟着泄露了。还有人在配置文件里写密钥,结果文件被日志系统采集走,密钥直接进了日志平台。
我的建议是至少做到这几层:
- 工作密钥不写死在 Java 代码里;
- 工作密钥不放在会进日志的配置文件中,而是从环境变量或者配置中心读取;
- 如果成本允许,使用专门的密钥管理服务,把密钥明文与业务代码彻底隔离;
- 设计密钥版本化机制,比如密文格式统一为
版本号:密文,这样密钥轮换时老数据还能用旧密钥解,新数据用新密钥加密,实现无缝过渡。
密钥轮换这块有一个很实用的过渡方案:在PasswordUtils里维护一个"当前密钥"和一个"历史密钥池"。解密时先尝试当前密钥,失败再遍历历史密钥。等所有老数据都重加密到新密钥之后,再逐步下线旧密钥。这个方案不需要停机,代价是解密逻辑会多一点分支,但相比强制替换全部密文,成本和风险低得多。
5. 实操过程:单元测试与项目集成
5.1 回环测试与锚点密文
工具类写完后,第一件事不是接业务,而是写测试。最基本的测试是回环测试:加密之后的密文能解密回原始明文,这个测试保证工具类的两个方向都通。我用的是常见的单测框架,直接看代码:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class PasswordUtilsTest { private static final String WORK_KEY = "k3y!2024"; private static final String IV = "initvect"; @Test void encryptThenDecryptShouldReturnOriginalText() { PasswordUtils utils = new PasswordUtils(WORK_KEY, IV); String plain = "hello 中文,这是一个回环测试"; String cipher = utils.encrypt(plain); assertNotNull(cipher); assertNotEquals(plain, cipher); assertEquals(plain, utils.decrypt(cipher)); } @Test void sameInputShouldProduceStableCipherWithFixedIv() { PasswordUtils utils = new PasswordUtils(WORK_KEY, IV); String plain = "stable input"; String cipher1 = utils.encrypt(plain); String cipher2 = utils.encrypt(plain); assertEquals(cipher1, cipher2); } }第二个测试看起来有点反直觉:我们前面刚说过固定 IV 有风险,为什么还要断言密文可复现?因为这里测试的不是"安全性",而是"稳定性"。在遗留系统迁移场景中,老数据必须能在新代码里解开,这就需要固定参数下密文结果可复现。安全诉求用"随机 IV"方案解决,稳定性诉求用"固定 IV"方案解决,两者适用于不同场景,要区分开。
除了回环测试,我强烈建议做"锚点测试":固定一个密钥和 IV,手动跑一次加密,把产生的密文写死在测试代码里。这样以后任何人改动算法参数(比如把 CBC 换成 ECB、把 UTF-8 改成 GBK),锚点测试都会直接失败,提醒你不要破坏老数据的兼容性。这个习惯在内部系统重构中价值巨大。
5.2 配置文件和数据库的接入
工具类封装完成后,接入业务是有套路的。数据库里存敏感字段时,我一般建议存密文,用varchar类型,长度按 Base64 后的大小预留。DES 是 8 字节分组加密,每 8 字节明文变成 8 字节密文,Base64 输出长度大概比原始明文多三分之一左右,再考虑到中文 UTF-8 编码后一个字符占 3 字节,预留 255 甚至 512 字符长度基本够用。
配置文件接入时,常见做法是采用ENC(密文)这种包裹格式,项目启动时统一解码。但这里要提醒一点:如果配置文件本身是通过模板渲染的,渲染工具可能会对+、/、=做转义,导致密文在启动时解析失败。所以配置文件里的密文尽量使用 URL Safe 变体,或者确保渲染工具不会改动这些字符。
5.3 日志与安全审查
日志是加解密工具类最容易泄露信息的环节。很多框架会默认打印方法入参和返回值,如果业务代码直接调passwordUtils.encrypt(明文),框架日志立刻把明文打出来了。我的做法是:
- 业务日志里禁止打印加解密入参和结果;
- 需要排查问题时,只打印密文,不打印明文;
- 在
PasswordUtils内部可以在加密前对明文做脱敏,但更根本的是要求上层代码不要记录敏感原文。
这个问题容易被忽略,但真出事儿的时候,日志平台泄露的数据比数据库泄露的还多。
6. 常见问题速查与避坑记录
6.1 解不开、乱码、报错怎么排查
我在实际排查和帮别人看代码的过程中,整理了一份高频问题表,按症状到解决方案的路径梳理如下:
| 症状 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
javax.crypto.BadPaddingException | 密钥不一致、密文被截断、密文经过 URL 编码后字符被替换 | 先用 Base64 解码密文,确认解出来的字节数组长度是 8 的倍数 | 核对密钥和 IV;确认传输过程中没有对密文字符串做额外转义 |
java.security.InvalidKeyException: Wrong key size | 密钥长度不是 8 字节 | 打印密钥字节数组的长度 | 密钥统一控制在 8 字节;超长密钥不要指望自动截断 |
| 解密成功但中文乱码 | 加密和解密时用了不同的字符集 | 检查getBytes()是否省略了StandardCharsets.UTF_8 | 明文转字节、字节转明文都显式指定 UTF-8 |
| 测试环境正常,生产环境失败 | 服务器默认字符集不同,比如 Windows 下 GBK、Linux 下 UTF-8 | 看启动日志里的file.encoding | 所有字符串和字节的转换都显式指定字符集 |
| 相同明文加密结果总是一样,怀疑不安全 | 使用了 ECB 模式或固定 IV 的 CBC | 查看转换字符串 | 需要高安全场景使用随机 IV,IV 与密文一同存储 |
Base64 字符串里出现+、/、=,导致 URL 传输报错 | 使用了标准 Base64 变体 | 检查编码入口 | 改用 URL Safe 变体 |
6.2 我从这些问题里总结的套路
排查加解密问题,我一般按"三段式"来定位:
第一段,验证 Base64 链是否畅通。把密文字符串用同样的解码器解开,看得到的字节数组长度是否正常、开头几个字节是否符合 DES 密文的特征。这一步能把编码层的问题先排除掉。
第二段,验证 Cipher 参数是否一致。加密和解密两侧的算法、模式、填充、密钥、IV 必须逐一比对。我见过最隐蔽的错误是:密钥字符串表面一样,但一边是 UTF-8 的getBytes(),另一边是 ISO-8859-1 的getBytes(),字节内容不同,解密自然失败。
第三段,验证数据完整性。Base64 字符串在传输过程中有没有被截断、有没有被 URL 编码器二次编码、有没有被数据库字段长度截断。这些都可能导致解密时报 padding 异常,而不是算法本身有问题。
还有一个经验是:不要在线上环境反复试错。每次解密失败都可能是同一个原因,但日志里只看到最终异常。正确的做法是在本地写一个最小复现用例,把线上的密文、密钥和 IV 都固定下来,跑一次就能看出是哪一段挂了。
7. 后续升级与边界提醒
7.1 从 DES 平滑迁移到 AES
DES 的密码学强度已经跟不上时代,凡是能改的场景,都应逐步迁移到 AES。好消息是,AES 在 Java 里的编程模型和 DES 几乎一样,SecretKeySpec、IvParameterSpec、Cipher的用法完全复用,只需要改三个地方:
- 算法名从
DES改成AES; - 密钥长度从 8 字节改成 16 字节(AES-128)或 32 字节(AES-256);
- 转换字符串改成
AES/CBC/PKCS5Padding。
迁移的时候可以走双算法并存的过渡期:新增数据用 AES 加密,老数据继续用 DES 解密。在PasswordUtils里维护两套实现,解密时先按密文里的版本标识选择算法,等老数据消化完再下线 DES 分支。
这里要特别提醒一点:AES-256 在部分 JDK 版本里默认受限于 JCE 策略文件,会报Illegal key size异常。实际上较新的 JDK 已经默认支持 AES-256,但如果你维护的是老版本 JDK 环境,需要确保没有额外限制。
7.2 用户口令不应该用可逆加密
说到PasswordUtils这个名字,很多人第一反应是拿来存用户密码。这是一个非常危险的误用。用户口令的正确存储方式是"加盐哈希",也就是单向的摘要算法加随机盐值,而不是可逆的对称加密。DES 加密是双向的,存进数据库的密文在密钥泄露后可以直接解出原文,用户口令一旦被还原,就是批量泄露。
我的建议是:PasswordUtils定位为"敏感数据保护工具",用于加密数据库中的证件号、手机号、第三方接口凭据这类需要还原原文的场景;用户口令的校验,单独使用 PBKDF2、BCrypt 这类专门的口令哈希算法,二者不要混用。我在项目里看到过有人用 DES 加密用户密码,密钥还硬编码在代码里,这种问题在评审时一定要拦下来。
7.3 一些更进一步的设计思路
如果这套工具类要继续演进,以下几个方向值得考虑:
- 引入随机 IV:当前实现固定 IV 便于回归测试,但在生产环境,更好的做法是每次加密生成随机 IV,把 IV 直接拼在密文头部,解密时先切分再解。这是"可测试性"和"安全性"的折中;
- 引入完整性校验:CBC 模式无法检测密文篡改,如果有人改了密文中的某个字节,解密输出会变成乱码,但程序不会主动报错。如果要防止篡改,可以升级到 AEAD 模式,比如
AES/GCM/NoPadding,它同时提供保密性和完整性; - 统一异常码:把加密失败、解密失败、参数非法映射成业务异常码,方便上层统一处理。
这些改动说起来都不复杂,但每改一处都要重新跑锚点测试,确保老数据的兼容性不被破坏。
回到最初的问题:Base64、DES、PasswordUtils 这三个概念放在一起,本质上是在回答"二进制数据如何安全、规范地完成加密、编码、存储、传输"这件事。Base64 解决二进制不可读的问题,DES 解决数据可逆保护的问题,PasswordUtils 解决代码复用和参数统一的问题。工具类本身不难写,真正难的是把每个细节的"为什么"想清楚。我自己在实际项目中把这些整理完之后,最大的感受是:排查加解密问题时,绝大多数疑难杂症都来自参数不统一、字符集不统一、密钥管理失控,而不是算法本身出了问题。所以封装的核心价值不在代码量,而在把可变的东西收敛到一处,把容易错的东西用测试固定下来。
最后再分享一个小技巧:在项目里做这种基础工具封装时,不管当前需求多么简单,都先把测试向量固化下来。哪怕只是自己本地跑出来的一条密文,写进测试注释里,一年后别人动这块代码时,会少走很多弯路。这个习惯我保持了好几年,每次都在关键时刻救回一命。