跳到主要内容

写了 20 年 Java,我发现 90% 的 if-null 和 try-catch 其实是因为缺了一条原则

· 阅读需 6 分钟

Postel 定律统治软件工程 45 年却没回答宽容应该到哪一步。提出有界宽容原则:对缺失和形式宽容,对内容严格。

你写过一百遍 if (str != null && !str.isEmpty()) { try { ... } catch (...) { ... } },却从没想过:"宽容"到底应该宽容到哪一步? Postel 定律统治了软件工程 45 年,没回答这个问题。这篇文章给出我的答案,并把它命名为 有界宽容原则(Bounded Leniency Principle)


一、先看一段你写过一百遍的代码

String ageStr = (String) map.get("age");
int age;
if (ageStr == null || ageStr.isEmpty()) {
age = 0;
} else {
try {
age = Integer.parseInt(ageStr.trim());
} catch (NumberFormatException e) {
age = 0; // 吞掉?记日志?抛出去?每个人写的都不一样
}
}

一个"取个年龄"的需求,写出 10 行模板代码。问题不在你菜,而在于——

Java 生态里,没人告诉你"宽容"应该宽容到哪一步。

  • map.get 不存在 → 返回 null(宽容)
  • Integer.parseInt(null) → 抛 NPE(不宽容)
  • Integer.parseInt("") → 抛异常(不宽容)
  • Integer.parseInt("abc") → 抛异常(应该不宽容)

四种情况,四种态度,毫无章法。每一个 Java 开发者都在用 try/catch 和 if-null 给这套混乱打补丁。

二、行业的两个极端,都不对

极端一:Postel 定律的"无限宽容"

"Be conservative in what you send, be liberal in what you accept." —— RFC 793, 1981

听起来很有道理。但 45 年后的今天,IETF 自己发了 RFC 9413 反思 Postel 定律:HTML 解析器互相宽容兼容了二十年,结果是没人敢写一个新解析器——因为"宽容"已经变成了一坨没人能定义的玄学。

无限宽容 = 没有契约。

极端二:Fail-Fast 的"无限严格"

Jackson 默认 FAIL_ON_UNKNOWN_PROPERTIES = true,前端多传一个字段就 500。 JDK Integer.parseInt(null) 直接 NPE。

无限严格 = 把"用户没填"和"用户填错了"当成同一种错。

但这两件事根本不是一回事

  • 用户没填年龄 → 业务上很常见,不是错误
  • 用户填了 "abc" → 这是真错,必须立刻暴露

把它们混为一谈,要么逼业务代码写满 try/catch,要么让脏数据偷偷流进数据库。

三、有界宽容原则:在两个极端之间画一条线

我把数据访问 API 面对的输入分成三个正交维度

维度含义例子
存在性 (Missing)值在不在路径不存在、null""
形式 (Type)类型对不对"123"int1boolean
内容 (Content)内容能不能解释"abc"int

然后给出三条规则:

🟢 Rule 1:Missing-Lenient(缺失宽容)

值不存在时,返回 null 或默认值,绝不抛异常。 理由:缺失是业务常态,不是错误。

🟢 Rule 2:Type-Lenient(形式宽容)

类型不同但内容可解释时,自动转换。 理由:前端传 "123" 还是 123 不该由后端代码关心。

🔴 Rule 3:Content-Strict(内容严格)

内容根本无法解释时,立刻抛异常,绝不静默兜底。 理由:脏数据必须在入口暴露,而不是流到数据库才被发现。

一句话:对"没有"和"形不同"宽容,对"内容是垃圾"严格。

四、判定表:把原则变成机械可判定的契约

任何一个数据访问函数 f(input) → output,对照这张表就知道它合不合规:

输入情况应有行为违反者示例
路径不存在返回 null / 默认值Map.get ✅ / Integer.parseInt(null)
值为 null返回 null / 默认值多数库 ❌
值为 ""返回 null / 默认值Integer.parseInt("")
"123" → int自动转换JDK 原生 Map ❌
1 → boolean自动转换JDK ❌
"abc" → int抛异常Hutool Convert.toInt("abc", 0) 静默返回 0 ❌
"2024-13-45" → date抛异常LocalDate.parse

这张表是这个原则最有价值的产物:你不用再争论"这个 API 设计得对不对",对着表打勾就行。

五、用这个原则审判主流库

MissingTypeContent评价
JDK 原生❌ NPE❌ ClassCastException✅ 抛异常三层全错前两层
Jackson 默认❌ 抛异常部分 ✅缺失层过严
Gson✅ null部分 ✅部分静默内容层有时太宽
Hutool Convert❌ 静默兜底内容层放水,脏数据天堂
Apache MapUtils部分 ✅❌ 静默返回默认值同上
有界宽容✅ 严格三层都对

看出来了吗?主流库里居然没有一个三层都对的——这就是这个原则存在的意义。

六、违反它会怎样:一个真实事故

某次促销活动,前端 bug 让 discount 字段偶发传成 "undefined" 字符串。

用 Hutool 的版本(内容宽容):

int discount = Convert.toInt(params.get("discount"), 0);
// "undefined" → 静默返回 0 → 用户全价下单 → 投诉 → 第二天才发现

用有界宽容的版本(内容严格):

int discount = ValUtil.toInt(params.get("discount"), 0);
// "undefined" → NumberFormatException → 上线前测试就挂了

宽容是有代价的,代价由谁付——是开发者还是用户——这就是原则要回答的问题。

七、与既有原则的关系

原则关系
Postel's Law(1981)本原则是它在数据访问层的精化:保留"宽容接收",但加上"内容必须严格"。
Fail-Fast在 Content 层一致,在 Missing 层反向:缺失不是错。
Tolerant Reader Pattern(Fowler)互补:TRP 讲怎么读结构未知的数据,本原则讲读出来之后怎么取值。
Principle of Least Astonishment是它的一个具体实例:getStr("不存在") 抛异常很惊人,toInt("abc") 静默返回 0 也很惊人。

八、给一个参考实现

我把这套原则实现在了开源库 dlz-kit 里,核心两个 API:

// 取值(Missing-Lenient + Type-Lenient)
JSONMap data = new JSONMap(jsonStr);
String name = data.getStr("user.name"); // 不存在 → null
Integer age = data.getInt("user.age", 18); // 不存在 → 18
Integer id = data.getInt("user.id"); // "123" → 123(自动转)

// 转换(Type-Lenient + Content-Strict)
ValUtil.toInt("123"); // → 123
ValUtil.toInt(null); // → null(不抛)
ValUtil.toInt(""); // → null(不抛)
ValUtil.toInt("abc"); // → NumberFormatException(抛!)

前两行帮你少写 10 行模板代码,最后一行帮你少修一个生产事故。

库只是参考实现,原则才是核心。你完全可以用任何语言、任何库实现它——只要对着第四节的判定表打勾就行。

九、把它带走

如果你只能记一句话:

对"没有"宽容,对"形不同"宽容,对"内容是垃圾"严格。

如果你只能记一张图:

宽容 ←——————————|——————————→ 严格
Missing Type │ Content
(缺失) (形式) │ (内容)

这条线就是"有界"

如果你想引用这个原则:

Bounded Leniency Principle:在 Missing 与 Type 维度上宽容,在 Content 维度上严格。是 Postel's Law 在数据访问层的精化。


讨论:你见过的最离谱的"宽容/严格"翻车现场是什么?评论区聊聊。

如果这个原则帮到你,点个赞让更多人看到——一个有名字的原则,才能在 Code Review 里被引用。