ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Kotlin自定义字面量实战:SQL、JSON、正则与单位场景解析

Kotlin自定义字面量实战:SQL、JSON、正则与单位场景解析 1. 自定义字面量到底是什么为什么值得折腾1.1 字面量只是“写着写着就习惯”的东西吗写过几天代码的人都见过字面量42是整数、hello是字符串、3.14是浮点数。语言把字面量的规则定死你按规则写编译器就按类型解读。但实际项目里90% 的字符串字面量根本不是在表达“字符串”语义而是在表达 URL、JSON、SQL、正则、日期、路径、颜色、正则、货币……字符串只是一个被滥用的容器。我常见到的写法是这种val query SELECT * FROM users WHERE id userId这行代码的逻辑漏洞先放一边单从“字面量”的角度看这里的SELECT * FROM users ...是一个字符串字面量但它真正的身份是 SQL。问题在于编译器不知道这是 SQL于是它不会帮你检查语法不会警告你拼接风险也不会在运行前做任何预处理。等到某天线上报了个语法错误你才意识到“原来这里需要转义”“原来这个字段名拼错了”。自定义字面量解决的就是这个错位问题——把“值”和“解释方式”解耦让字面量在进入业务逻辑之前先经历一次我们自定义的解析、校验、转换过程。它在很多语言里形态不一样Swift 走协议C 走后缀运算符C# 走处理器类Kotlin 走的是模板函数和invoke约定。但内核是一致的给字面量赋予类型和语义不再是裸字符串。这篇实战文章我会以 Kotlin 为主线把 SQL 安全字面量、JSON 字面量、正则字面量、单位字面量四种场景完整写一遍再对比 Swift、C、C# 的实现路子最后把踩过的深坑和排查技巧整理成清单。适合已经会写 Kotlin、但想给项目折腾“更顺手的 DSL 写法”的人也适合想搞明白“别的语言里自定义字面量到底怎么设计”的读者。1.2 自定义字面量到底解决了哪类问题我自己的体会是自定义字面量在四类场景里收益最大。一类是安全检查前置。比如 SQL 里的参数占位、正则表达式的合法性、路径拼接是否越界这些检查如果放到运行期做大多数时候测不出来只有到了特定输入才会炸。通过自定义字面量可以在字面量被实例化的时候就完成校验越早失败定位成本越低。第二类是代码可读性。一个字符串字面量2027-03-14 08:30:00和一个MeetingTime类型的字面量效果完全不同。前者是数据后者是领域对象。DSL 性质的代码往往读起来像英语句子靠的就是字面量被“定制”过。第三类是性能预计算。正则表达式每次新建都要编译如果在字面量层面缓存编译结果相当于把成本从高频路径挪到了初始化阶段。C 的operator_re设计就是冲着这个去的。第四类是统一转换逻辑。一个 JSON 字符串有的地方需要解析成对象有的地方只需要校验格式有的地方要转成格式化输出。与其每次写那段解析代码不如定义一个 JSON 字面量类型让所有使用方拿到就已经是解析好的对象。但也要说清楚自定义字面量不是万能药。它本质上是在语言语法边缘做文章滥用会让新人看不懂代码也会让 IDE 的跳转、重构、自动补全变得迟钝。所以我习惯问自己一个问题这个字面量是不是有稳定的解释规则如果同一个字符串在不同地方含义不一样那就不该做成全局字面量而是局部函数。1.3 什么时候别用自定义字面量有一条我踩了很多次才想明白的教训不要为了“像魔法”而自定义字面量。比如有人喜欢给颜色写0xFF8800这种字面量但项目里颜色来源大部分是设计稿写个函数color(#FF8800)就已经够清晰了没必要搞一套自定义编译期处理的机制。再比如日志标签、枚举转字符串这种事情本质是类型的序列化问题不是字面量问题。还有一个更现实的问题团队协作。自定义字面量一旦进了公共模块所有人被迫接受你的“方言”。如果项目的核心成员不能在三分钟内解释清楚这个字面量的解析规则那它带来的认知成本大概率高于收益。我见过一个项目给金额定义了12.5_money本地跑得挺好后来 PHP 端同事接手维护每看到一个下划线都要问一遍“这是什么魔法”。后来还是改回了函数调用。所以真正适合自定义字面量的场景应该是字面量的解释规则固定、使用频率高、校验或转换逻辑集中可控。满足这三点才值得去折腾。2. 主战场Kotlin 里的自定义字面量实战2.1 把字符串变成 SQL第一版安全字面量Kotlin 没有像 C 那样的原生“后缀字面量运算符”所以大多数自称自定义字面量的写法都是通过伴生对象加模板字符串或者顶层函数来实现的。我先说一种最直白的写法目标是让一个拼接 SQL 的常见操作变得安全一些。先看目标用法val sql sql SELECT * FROM users WHERE id ${userId} AND status ACTIVE 注意这里用了 Kotlin 的 Raw String三引号和字符串模板。sql实际上不是一个关键字而是一个函数。我们让它的签名长这样data class SqlStatement( val text: String, val params: ListAny? ) fun sql(block: StringBuilder.() - Unit): SqlStatement { val text StringBuilder().apply(block).toString() return SqlStatement(text, emptyList()) }但字符串模板天然会把${userId}插值成字符串拼接这做不到参数化。想真正实现“参数占位”得换一条路给插值的内容做类型标记。我实际项目里用的方案是这样定义Param接口让普通值自动包裹成Param实例然后在SqlBuilder里收集参数、生成占位符、拼接静态文本。sealed interface SqlPart { val raw: String val params: ListAny? } data class PlainPart(private val s: String) : SqlPart { override val raw: String get() s override val params: ListAny? get() emptyList() } data class BoundParam(private val value: Any?) : SqlPart { override val raw: String get() ? override val params: ListAny? get() listOf(value) } class SqlBuilder { private val parts mutableListOfSqlPart() fun plain(s: String) apply { parts PlainPart(s) } fun bind(v: Any?) apply { parts BoundParam(v) } fun build(): SqlStatement SqlStatement( text parts.joinToString() { it.raw }, params parts.flatMap { it.params } ) } fun sql(block: SqlBuilder.() - Unit): SqlStatement SqlBuilder().apply(block).build()用法变成val stmt sql { plain(SELECT * FROM users WHERE id ) bind(userId) plain( AND status ) bind(status) }这已经不是“字面量”了更像一个小方言。真正贴合字面量的做法是用字符串模板捕获表达式后再结构化解析但那个方案复杂度和收益不成比例我建议普通项目直接回到函数加参数绑定的方案。关键是理解背后的取舍模板字符串适合人读参数化函数适合机器执行不能指望一个语法同时兼顾两者。如果你只是想要安全的 SQL 组装上面的SqlBuilder已经足够如果你想要看起来像 SQL 的写法那要么接受字符串拼接的风险要么引入一个真正的 SQL DSL。2.2 JSON 字面量解析失败就该在配置阶段炸出来JSON 是另一个高频率字符串场景。最尴尬的体验是配置文件里写了一段 JSON项目启动时不校验等到某条业务逻辑真正解析到这一段时才抛异常。如果这段代码只在特殊路径触发问题可能潜伏好几个月。我的做法是定义一个JsonElement类型再给String.Companion写一个扩展函数让所有字符串字面量都能显式转换成 JSONimport org.json.JSONObject import org.json.JSONArray sealed class JsonLiteral { abstract val raw: String abstract fun parse(): Any data class ObjectValue(override val raw: String) : JsonLiteral() { override fun parse(): Any JSONObject(raw) } data class ArrayValue(override val raw: String) : JsonLiteral() { override fun parse(): Any JSONArray(raw) } companion object { fun from(raw: String): JsonLiteral { val trimmed raw.trim() return when { trimmed.startsWith({) - ObjectValue(trimmed) trimmed.startsWith([) - ArrayValue(trimmed) else - throw IllegalArgumentException(不是合法 JSON 字面量: $raw) } } } } fun String.asJson(): JsonLiteral JsonLiteral.from(this)单看这个不够酷因为调用方还是要写.asJson()。如果希望字面量本身更像原生 JSON可以在一个上下文里绑定接收者class JsonConfigLoader { fun load(config: String): Any config.asJson().parse() }但真正的爽点在于配合 Kotlin 的companion object invoke约定实现“看起来像关键字”的模板写法object json { operator fun invoke(content: String): JsonLiteral JsonLiteral.from(content.trimIndent()) } // 使用 val config json { name: demo, timeout: 30 } 这里的json是一个单例对象后面紧跟三引号字符串在 Kotlin 语法里等价于json.invoke(字符串)。编译器不会拦你看起来就像自定义了一个 JSON 字面量。我用这个方案在配置模块里替换了原先几十处散落的JSONObject(字符串)调用收益最明显的是启动期校验。配置中心的 JSON 一旦写错服务启动直接报错而不是等接口被调用时才 500。这个体验差异非常大。有个细节要提醒trimIndent()是三引号字符串排版常用的方法但如果 JSON 内容是缩进不对称的可能导致首尾判断失误。稳妥做法是先用trimIndent()再用trim()两遍处理防空格影响。这段逻辑我已经放进了JsonLiteral.from里。2.3 正则字面量编译一次报错在配置阶段正则表达式是另一个最该“自定义字面量化”的东西。Java 的Pattern.compile在每次用到时都要重新编译正则——虽然底层有缓存但缓存命中率取决于你是否复用了同一个Pattern对象。很多人图省事直接每行代码里写Regex(规则)性能开销不大但异常定位很痛苦。Kotlin 的正则也有同样问题。我实际踩过的坑是写了一个线上规则引擎规则文本从数据库里来其中一条正则写错了一个括号导致匹配时抛PatternSyntaxException报警是凌晨三点。后来我在规则引擎的配置层引入了一个正则字面量封装JvmInline value class RegexLiteral(val source: String) { private val compiled: Regex by lazy { Regex(source) } fun matches(input: String): Boolean compiled.containsMatchIn(input) companion object { fun from(source: String): RegexLiteral { // 配置阶段先编译一次失败立刻抛错 Regex(source) return RegexLiteral(source) } } } object regex { operator fun invoke(expression: String): RegexLiteral RegexLiteral.from(expression.trimIndent()) } val pattern regex ^[A-Z0-9._%-][A-Z0-9.-]\.[A-Z]{2,}$ 这里用了JvmInline value class避免了每次用正则都创建完整对象。关键点在于from()里先主动调用一次Regex(source)让语法错误在字面量创建时就暴露。lazy保证真正匹配时才编译但异常窗口已经前移了。你说这不是字面量吧用起来确实是字面量的感觉你说它是吧本质上就是个单例对象的invoke。我觉得实战中不必纠结概念边界重要的是获得了三个具体好处创建时校验错误前置。编译结果被缓存热点路径不再重复编译。所有正则写法的统一入口方便以后统一替换。2.4 单位与度量字面量让计算带上量纲单位换算是个典型场景。比如前端配置了超时时间30秒后端逻辑却默认毫秒两边一差就是 1000 倍。我做的方案是定义了一个DurationLiteral让代码里写数字时自带单位JvmInline value class DurationMs(val value: Long) { operator fun plus(other: DurationMs): DurationMs DurationMs(value other.value) operator fun times(factor: Long): DurationMs DurationMs(value * factor) val seconds: Double get() value / 1000.0 } object ms { operator fun invoke(value: Long): DurationMs DurationMs(value) } object sec { operator fun invoke(value: Long): DurationMs DurationMs(value * 1000) }使用起来的感觉很重要val timeout sec(30) val retryDelay ms(500) val total timeout retryDelay这接近自定义字面量但其实是工厂对象。如果项目里能用 Kotlin 的Language注解也可以在 IDE 里获得高亮提示——我后面会讲到。更接近原生字面量的方式是用 Kotlin 的约定invoke配合number字面量但说实话 Kt 里没有 Python 那种百分号单位支持能做到30.seconds已经是极致想要30s只能靠编译器插件。所以我建议团队最多做到值对象封装不要在语法层面强行拗。量纲的价值在哪儿我举一个实际例子。某次功能里写了val durationMinutes 5后来改了需求要按毫秒计算结果下游判断超时逻辑没改直接导致线上数据错乱。如果代码里所有时间都走DurationMs类型编译器会在类型不匹配的地方直接报红这种错根本藏不住。3. 跨语言对比Swift、C、C# 的自定义字面量3.1 Swift 的 ExpressibleByStringLiteral 协议Kotlin 是在语法层面借用对象调用Swift 则是语言内建支持。Swift 的ExpressibleByStringLiteral允许任意类型从字符串字面量直接初始化连转换函数都不用调用。举个例子struct UserID: ExpressibleByStringLiteral { let value: String init(stringLiteral value: StringLiteralType) { // 在这里做校验、归一化、格式修正 self.value value.trimmingCharacters(in: .whitespaces) } } let userId: UserID u-12345这里的u-12345在类型标注为UserID时会被 Swift 编译器自动转成通过init(stringLiteral:)实例化的对象。它不只是在调用层“看起来像字面量”而是编译器真正把它作为字面量处理支持默认值、数组、字典里的字面量初始化。这种机制比 Kotlin 猛的地方在于Swift 对编译器有着完整的字面量协议体系类型强制时自动触发Kotlin 则更多地依赖开发者按约定调用。不过 Swift 也有坑。自定义了字符串字面量转换后所有赋值场景都会被编译器接管一旦校验逻辑重了每行初始化都会慢。而且如果类型实现了多个ExpressibleBy*协议编译器在选择用哪个协议初始化时可能产生歧义。我建议 Swift 里做这类转换时坚持只实现一个协议别贪多。3.2 C 用户自定义字面量运算符C 的玩法是最“原生”的。它用operator后缀直接在编译期解析而且可以用constexpr写编译期计算constexpr long long operator_KB(unsigned long long size) { return size * 1024; } long long file_limit 100_KB;100_KB在 C 里被编译器识别为一个字面量调用100是参数_KB是标记。底层原理其实很简单编译期把数字和字符串切片传给重载函数返回什么类型就得到什么类型。C 的强项是可以依赖constexpr做真·编译期计算很多配置项不需要运行时初始化。弱点是重载解析偶尔会让人抓狂数字后缀和字符串后缀是两个完全不同的重载集合写出abc_KB不会报“没实现”而是报“没有匹配的重载”新人排查半天才发现是类型写错了。我觉得 C 这个设计最值得借鉴的是后缀运算符的命名规范。标准建议自定义后缀要么以下划线开头要么保证不与标准库冲突。团队内部如果大量使用自定义字面量必须维护一张后缀清单否则三个月后没人记得_KB是千字节还是千比特。3.3 C# 插值字符串处理器的思路C# 的插值字符串处理器是另一种思路不改变字面量语法而是改变字符串插值时的“收集器”。它通过[InterpolatedStringHandler]特性标记一个类型让编译器把插值字符串的各个部分作为参数传给处理器[InterpolatedStringHandler] struct SqlHandler { private StringBuilder _builder new(); private readonly Listobject _parameters new(); public SqlHandler(string literal, int slotCount) { ... } public void AppendLiteral(string value) _builder.Append(value); public void AppendFormattedT(T value) { _builder.Append(p _parameters.Count); _parameters.Add(value!); } public string Build() _builder.ToString(); }然后配合SqlCommand构造调用方式仍然是常见的插值字符串SqlCommand cmd new SqlCommand($SELECT * FROM users WHERE id {userId}, conn);编译器看到SqlCommand接收一个SqlHandler就自动把{userId}转成参数而不是字符串拼接。这套机制的亮点是“不改变调用代码风格只改变拼接行为”非常适合既有代码库渐进改进。C# 这条路给我最大的启发是自定义字面量不一定要创造新语法也可以把现有的字面量语法转换成另一种语义——前提是类型系统能识别这个语境。Kotlin 虽然没有等价的处理器特性但如果在调用点明确传入目标类型利用函数重载其实也能达到类似效果只是形态没那么优雅。4. 实战里的几个深坑与排查技巧4.1 编译期校验 vs 运行期校验别把幻想当设计很多初学者以为自定义字面量是“编译期魔法”写出来之后才发现是运行期调用于是对错误时机产生误解。Kotlin 的operator fun invoke、Swift 的init(stringLiteral:)、C 的非constexpr后缀执行时机都是运行时C 如果写成constexpr则可以在编译期求值。如果校验逻辑写在这些入口里报错只能发生在程序启动阶段而不是编译阶段。能接受“启动爆发错误”的场景比如配置加载、依赖注入、路由注册都适合这种设计接受不了运行期任何错误的场景比如服务里一段非常冷门的规则匹配则应该考虑编译器插件或者构建期脚本。我在做一个规则引擎配置的时候就是靠regex{}字面量在服务启动时把上百条规则全部预编译一遍。启动耗时多了几百毫秒但换来了任何语法错误在灰度环境就能暴露的确定性对比之前在用户请求里才抛异常的体验这笔开销非常值。判断标准很简单校验数据的来源是代码字面量还是外部输入代码字面量的校验可以放心前置外部输入则永远要留在数据入口做防御性检查不能寄希望于自定义字面量帮你挡住。4.2 性能与缓存字面量不是每次执行都解析的另一个被高估的点是性能收益。很多人以为做了“自定义字面量”就自动获得了缓存、预编译、零开销但真相是大多数实现只是“每次调用时执行相同的转换函数”而已。C 里operator_KB可能真的编译期算好了Kotlin 里json{...}每次调用都会重新创建一个JsonLiteral对象内部解析的JSONObject也重新构建。如果这段代码在热点循环里跑百万次性能并不会比普通字符串解析好。正确的做法是把字面量当成“配置对象模板”在初始化阶段解析一次之后复用。比如前面正则例子里的compiled: Regex by lazy以及 Json 示例里如果解析结果经常复用应该把parse()的结果缓存为val字段。Kotlin 的val by lazy在这里很好用但要注意线程模式。lazy默认是SYNCHRONIZED如果字面量在多线程环境中首次访问可能产生短暂的线程阻塞。对于纯只读配置场景开LazyThreadSafetyMode.PUBLICATION更省如果你确认只有一个线程访问用NONE最快。我自己的性能排查步骤是先用 profiler 看有没有重复解析再看是否缓存的是缓存对象本体而不是解析结果最后看是不是所有调用点都在热路径上。大部分所谓“字面量太慢”的问题往往不是字面量机制慢而是忘了缓存。4.3 安全性SQL、路径、正则里的注入与转义自定义字面量如果处理不好安全边界会比普通字符串更容易埋雷。原因很反直觉字面量看起来经过了“处理”让人误以为它已经消毒了。最常见的错误是以为自己写了参数化 SQL 字面量实际上只是做了静态文本拼接。回到 2.1 的SqlBuilder关键在于bind()函数是否真的生成了占位符而不是把值拼进字符串。有人封装的时候偷懒直接String.format(sql, userId)这就等于脱了裤子放屁。正则也有类似问题。如果正则的规则里允许用户输入必须在规则文本通过regex{}字面量之后再做一层用户输入的转义。Regex.escape()是 Java/Kotlin 自带的转义方法但很多人根本没留意到导致正则注入。路径拼接同理// 错误示范不做边界检查 fun path(base: String, child: String) Path(base).resolve(child).toString()这类代码适合做成自定义目录字面量然后在校验函数里强制检查..、绝对路径等危险模式。安全性的关键是自定义字面量只能帮你集中做校验不能替你决定该信任谁。所有外部输入无论经不经过字面量包装都要在真正进入系统边界时再验证一遍。4.4 团队规范有的字面量只适合“局部禁用”自定义字面量最大的治理问题是“传染性”。如果一个项目里定义了全局的regex、json、sql三个对象新人写代码会想“既然有我就用一下吧”于是项目里出现大量含义模糊的简写调用别人的代码里读起来全是魔法。我的经验是做一个分层约束第一核心层允许定义基础字面量比如regex、json、duration但必须配有详尽的 README 说明解析规则、缓存规则、错误时机、替换方案。第二业务层禁止随意定义新的全局字面量对象。业务模块里的自定义字面量一律使用局部函数或伴生对象限定作用域确保不会污染别的模块。第三代码评审里专门有一条任何新增的“看起来像语法”的 API必须说明它的解析规则、性能特征、错误时机、与原生写法的差异四件事。说不清楚就不合入。这不是搞官僚而是自定义字面量本质是在修改团队默认的“读代码方式”。一个语法糖的认知成本往往比它节省的 10 行代码更大只是我们习惯性地高估了自己写魔法代码的效率低估了同事的阅读成本。5. 我的一些私人经验5.1 先写“原版代码”再决定要不要字面量化每次想做自定义字面量我都强迫自己先写一段不用它的代码跑通测量耗时和可读性。如果原版代码已经简洁到不需要魔法就果断放弃自定义字面量。自定义字面量的价值必须体现在原版代码有重复、有遗漏、有错位。拿 SQL 来说如果项目里只有一个地方拼 SQL我为它引入 SqlBuilder 纯属过度设计。如果有二十处而且已经出现过一次 SQL 注入风险那这个字面量就值了。5.2 IDE 插件是玩自定义字面量的暗线Kotlin 里除了invoke约定还有一个经常被忽略的工具Language(JSON)或Language(RegExp)注解。IntelliJ 系 IDE 会针对注解做字符串内容的高亮、折叠、错误检查。比起自己写正则字面量加上注解的普通字符串在 IDE 里的体验已经接近“自定义字面量”而且完全不需要运行时解析import org.intellij.lang.annotations.Language fun loadConfig(Language(JSON) json: String) { ... }我后来的很多场景其实都是“注解先上自定义语法后补”。如果 IDE 高亮已经足够很多时候就没有必要再造 DSL。这对于团队协作来说是最低成本的方案——至少同事不需要学习新语法。5.3 从错误信息入手设计字面量一个成熟的自定义字面量不仅要在成功路径上给人感觉更要在失败路径上给人清晰反馈。我在设计JsonLiteral.from()时写的第一版只是抛出IllegalArgumentException后来发现同事看到长 JSON 里少一个逗号的报错信息完全无法定位于是改成解析器抛出的异常附加上上下文片段throw IllegalArgumentException(JSON 解析失败附近内容: ...${raw.substring(0, minOf(80, raw.length))}...)这个改动花了我 10 分钟但明显缩短了同事定位错误的时间。强烈建议所有自定义字面量的校验入口都做这个动作尽量把字面量原始内容和错误信息一起抛出不要只丢一个内部异常。5.4 未来的扩展编译器插件是终极形态如果你真的需要完全原生的自定义字面量比如想要 Kotlin 能写30s这种后缀唯一正规路线是写一个编译器插件在语法分析阶段把特定模式的字面量转成函数调用。我之前调研过 Kotlin 编译器插件的实现思路AST 阶段识别字符串模板之后替换为自定义节点再做降级。这个方案能获得最完整语法体验但维护成本极高需要跟随编译器版本升级。对大部分项目来说用object invoke、value class、注解高亮这些组合拳已经能达到 90% 的效果。剩下的 10% 语法糖性价比太低不值得投人力。说回我自己最顺手的一套组合正则用注入缓存、JSON 用伴生对象解析前置、时间用值对象做量纲、SQL 用 Builder 做参数化所有入口统一风格、统一错误信息格式。几年实践下来这套东西的维护成本很低团队新人花半小时就能看懂线上事故里跟字面量相关的排查也几乎绝迹。自定义字面量的价值不是炫技而是让代码的意图更接近它的运行方式让错误更早暴露在离源头最近的位置。这就是我一直折腾这个小玩意的原因。
返回列表