# 恢复与降级:只为缺失的可选配置提供默认值
学习目标:把“能分类错误”推进到“有边界地恢复”,让错误配置继续失败,而不是被默认值掩盖。
前置知识:区分失败原因中的错误类别与基本文件操作。版本遵循主题基线。
导航:问题与边界 · 关键差异 · Go · Java · Python · 运行与验证
# 问题与边界
可选配置只接受精确字节 fast、safe,缺失时返回默认 safe,并返回来源标记 default;真实文件产生 file 标记。空文件、坏内容、权限错误和其他 I/O 错误都必须失败。程序调用方知道配置是否降级,不能把一个默认值伪装成成功读取的值。
本例假定路径来自可信的本地配置目录,“可选”意味着读取操作报告不存在即可降级,包括末级文件或祖先目录不存在。若业务只允许末级缺失,则需要更强的目录生命周期约定,不能用一次 exists 检查消除竞态。本篇不重试,不添加缓存,不将默认值写回文件,不证明默认模式适合所有系统。
# 关键差异
| 语言 | 恢复位置 | 未恢复错误 | 成功结果 |
|---|---|---|---|
| Go | errors.Is 后的窄分支 |
原样返回错误 | 结构体和 nil |
| Java | 只捕获 NoSuchFileException |
受检异常继续传播 | 不可变 record |
| Python | 只包围读取的 except |
内容校验在恢复块外 | 元组 |
选择三种调用模型与分类篇对应,便于观察策略变化而不是增加语法列表。读取器处理事实,默认值属于可选配置的业务边界。硬编码默认值是本例契约,不是通用的“异常处理最佳实践”。
# 各语言实现
# Go
预期四行:safe default、fast file、safe file、invalid rejected。
Gopackage main
import (
"errors"
"fmt"
"os"
"path/filepath"
)
type Config struct { Mode, Source string }
var ErrInvalid = errors.New("invalid config")
func optional(path string) (Config, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) { return Config{"safe", "default"}, nil }
if err != nil { return Config{}, fmt.Errorf("read optional config: %w", err) }
mode := string(data)
if mode != "fast" && mode != "safe" { return Config{}, ErrInvalid }
return Config{mode, "file"}, nil
}
func run() (err error) {
dir, err := os.MkdirTemp("", "optional-")
if err != nil { return err }
defer func() { err = errors.Join(err, os.RemoveAll(dir)) }()
path := filepath.Join(dir, "mode")
got, err := optional(path)
if err != nil { return err }
if got != (Config{"safe", "default"}) { return errors.New("wrong fallback") }
fmt.Println(got.Mode, got.Source)
for _, mode := range []string{"fast", "safe"} {
if err := os.WriteFile(path, []byte(mode), 0600); err != nil { return err }
got, err := optional(path)
if err != nil { return err }
if got != (Config{mode, "file"}) { return errors.New("wrong source") }
fmt.Println(got.Mode, got.Source)
}
for _, bad := range []string{"", "fast\n", "broken"} {
if err := os.WriteFile(path, []byte(bad), 0600); err != nil { return err }
if _, err := optional(path); !errors.Is(err, ErrInvalid) {
return fmt.Errorf("expected invalid, got %v", err)
}
}
fmt.Println("invalid rejected")
return nil
}
func main() { if err := run(); err != nil { panic(err) } }
不能将 if err != nil 一律替换成默认值;只有 errors.Is(err, os.ErrNotExist) 是恢复条件。Config{} 伴随错误时不代表有效模式。参见 os 错误与读取接口 和 errors.Is。
# Java
预期同 Go。
Javaimport java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
public class Main {
record Config(String mode, String source) {}
static class InvalidConfig extends Exception {
InvalidConfig() { super("invalid config"); }
}
static Config optional(Path path) throws IOException, InvalidConfig {
byte[] data;
try { data = Files.readAllBytes(path); }
catch (NoSuchFileException e) { return new Config("safe", "default"); }
String mode = new String(data, StandardCharsets.US_ASCII);
if (!mode.equals("fast") && !mode.equals("safe")) throw new InvalidConfig();
return new Config(mode, "file");
}
static void show(Config got, Config want) {
if (!got.equals(want)) throw new AssertionError("wrong config");
System.out.println(got.mode() + " " + got.source());
}
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("optional-");
Path path = dir.resolve("mode");
try (AutoCloseable cleanup = () -> {
Files.deleteIfExists(path);
Files.delete(dir);
}) {
show(optional(path), new Config("safe", "default"));
for (String mode : new String[]{"fast", "safe"}) {
Files.write(path, mode.getBytes(StandardCharsets.US_ASCII));
show(optional(path), new Config(mode, "file"));
}
for (String bad : new String[]{"", "fast\n", "broken"}) {
Files.write(path, bad.getBytes(StandardCharsets.US_ASCII));
try {
optional(path);
throw new AssertionError("invalid accepted");
} catch (InvalidConfig expected) {
if (!expected.getMessage().equals("invalid config")) {
throw new AssertionError("wrong failure");
}
}
}
System.out.println("invalid rejected");
}
}
}
这里只捕获一次读取的缺失异常,而不是用一个覆盖整个启动过程的 catch(IOException)。ASCII 解码的非 ASCII 替换字符不可能等于两个合法单词,因此不会放宽字节协议;本例不返回原始非法文本。record 表示固定的结果数据,并不替代业务校验,参见 Java record 规则。
# Python
预期同 Go。
Pythonfrom pathlib import Path
from tempfile import TemporaryDirectory
class InvalidConfig(ValueError):
pass
def optional(path: Path) -> tuple[str, str]:
try:
data = path.read_bytes()
except FileNotFoundError:
return "safe", "default"
if data not in (b"fast", b"safe"):
raise InvalidConfig("invalid config")
return data.decode("ascii"), "file"
with TemporaryDirectory(prefix="optional-") as directory:
path = Path(directory) / "mode"
got = optional(path)
assert got == ("safe", "default")
print(*got)
for mode in ("fast", "safe"):
path.write_bytes(mode.encode("ascii"))
got = optional(path)
assert got == (mode, "file")
print(*got)
for bad in (b"", b"fast\n", b"broken"):
path.write_bytes(bad)
try:
optional(path)
except InvalidConfig as error:
assert str(error) == "invalid config"
else:
raise AssertionError("invalid accepted")
print("invalid rejected")
缩小 try 范围防止今后在校验阶段增加其他文件访问时,意外把该访问的缺失也当成可选配置缺失。异常匹配规则见 Python 处理异常。元组是返回约定,不是静态运行时校验。
# 迁移误区与适用限制
value or default会把空配置和缺失混为一谈;空文件在这里应失败。- 先写默认文件再读取会改变磁盘状态和错误模型;本例只返回内存值,不做持久化。
- 遇到坏配置继续启动可能隐藏部署错误;这里由最外层报告失败,调用者不能使用失败时的零值。
- 默认值来源是可观察结果。若未来
safe的业务含义改变,修改默认值与修改有效输入集合是两项独立的兼容性决定。 - 权限错误和 I/O 故障的分支保留,但例子没有声称在所有平台实际诱发过这些故障。
# 运行与验证
三个程序均自建、清理临时目录。覆盖缺失降级、合法文件覆盖默认、相同 safe 值的不同来源、空值与尾随换行拒绝。Go 合并目录清理错误,Java 资源块按 suppressed 规则保留主错误,Python 使用上下文异常链;资源释放和错误优先级详见确定性清理。本文未执行示例,统一结果见验证记录。