导读:本期聚焦于关中王创作的《如何在 Go 中实现 SQL 语句格式化:从关键字换行到嵌套结构的稳健方案》,敬请观看详情。直接按换行符拆分SQL再拼空格,遇到子查询和CASE WHEN往往立刻乱掉。要写出可维护的Go格式化器,核心不是正则替换,而是先做词法扫描,再按括号深度和关键字层级决定换行与缩进。本文从最基础的关键字换行方案起步,说明它为何在字符串、注释和嵌套子查询上失效;接着实现一个轻量SQL词法分析器,区分关键字、标识符、字符串、注释和符号,保证不会错误断行;随后引入括号深度与缩进栈,让SELECT子查询、JOIN、UNION和CASE表达式都能稳定输出;最后讨论注释保留、多语句边界和常见边界条件。读完可以用纯Go标准库实现一个适合日志与调试的SQL美化工具。

SQL语句格式化看起来只是把关键字放到行首,但真正处理子查询、CASE WHEN和注释时会迅速变得复杂。直接用正则替换或者按空格拆词,往往会在字符串里的FROM和注释处的WHERE上错误断行。要在Go里得到一个稳健的格式化器,合理的路径不是增加更多替换规则,而是先做词法分析,再根据括号深度和语法层级决定换行与缩进。本文会从一个最小实现开始,逐步补全到能处理嵌套结构。

如何在 Go 中实现 SQL 语句格式化:从关键字换行到嵌套结构的稳健方案

一、从关键字换行开始:最小实现为什么不够用

一个直观思路是遍历关键字列表,在关键字前插入换行符。下面的函数使用strings.ReplaceAll完成这个操作。它假设关键字前后都有空格,能处理SELECT、FROM、WHERE等常见子句。对于只有单层查询的SQL,输出确实比原始字符串可读性更好。

package main

import (
    "strings"
)

func formatBasic(sql string) string {
    keywords := []string{"select", "from", "where", "group by", "order by", "limit", "and", "or"}
    out := sql
    for _, kw := range keywords {
        out = strings.ReplaceAll(out, " "+kw+" ", "\n"+strings.ToUpper(kw)+" ")
    }
    return out
}

但这个实现有三个明显问题。第一,字符串内容会被误伤,例如SELECT 'from' AS source会被错误替换成SELECT后换行再接FROM;第二,关键字大小写不敏感时无法统一,原始SQL可能是小写、大写或混合大小写;第三,嵌套子查询没有缩进,当括号内出现另一个SELECT时,所有内容仍然堆在左侧。更关键的是,这种方案完全无法判断当前词素是否处于注释中。

如果继续沿着字符串替换的方向修补,就要不断加入排除条件:跳过引号内内容、跳过注释、判断括号深度。这些条件堆到最后,维护成本已经超过重新实现一个简单的词法分析器。因此更好的做法是先把SQL拆成token,再针对token设计格式化规则。

二、先做词法分析:把关键字、字符串、注释分开

词法分析器的任务是将SQL文本切分为有类型的token,至少要区分普通单词、字符串、注释和符号。其中单词可以再判断是否为关键字,符号主要指括号、逗号、分号。下面是一个轻量实现,利用[]rune遍历输入,避免直接处理多字节字符时出错。

package main

import (
    "fmt"
    "strings"
    "unicode"
)

type tokType int

const (
    tokWord tokType = iota
    tokString
    tokComment
    tokSymbol
    tokEOF
)

type token struct {
    typ tokType
    val string
}

type lexer struct {
    src []rune
    pos int
}

func (l *lexer) next() (token, bool) {
    for l.pos < len(l.src) && unicode.IsSpace(l.src[l.pos]) {
        l.pos++
    }
    if l.pos >= len(l.src) {
        return token{typ: tokEOF}, false
    }
    c := l.src[l.pos]
    if c == '\'' {
        start := l.pos
        l.pos++
        for l.pos < len(l.src) {
            if l.src[l.pos] == '\'' {
                if l.pos+1 < len(l.src) && l.src[l.pos+1] == '\'' {
                    l.pos += 2
                    continue
                }
                l.pos++
                break
            }
            l.pos++
        }
        return token{typ: tokString, val: string(l.src[start:l.pos])}, true
    }
    if c == '-' && l.pos+1 < len(l.src) && l.src[l.pos+1] == '-' {
        start := l.pos
        for l.pos < len(l.src) && l.src[l.pos] != '\n' {
            l.pos++
        }
        return token{typ: tokComment, val: string(l.src[start:l.pos])}, true
    }
    if c == '/' && l.pos+1 < len(l.src) && l.src[l.pos+1] == '*' {
        start := l.pos
        l.pos += 2
        for l.pos+1 < len(l.src) && !(l.src[l.pos] == '*' && l.src[l.pos+1] == '/') {
            l.pos++
        }
        if l.pos+1 < len(l.src) {
            l.pos += 2
        }
        return token{typ: tokComment, val: string(l.src[start:l.pos])}, true
    }
    if strings.ContainsRune("(),;", c) {
        l.pos++
        return token{typ: tokSymbol, val: string(c)}, true
    }
    start := l.pos
    for l.pos < len(l.src) && !unicode.IsSpace(l.src[l.pos]) &&
        !strings.ContainsRune("(),;'", l.src[l.pos]) {
        if l.src[l.pos] == '-' && l.pos+1 < len(l.src) && l.src[l.pos+1] == '-' {
            break
        }
        l.pos++
    }
    if l.pos == start {
        l.pos++
    }
    return token{typ: tokWord, val: string(l.src[start:l.pos])}, true
}

这个扫描器在遇到单引号时会进入字符串扫描,并处理连续两个单引号的转义;遇到--和/* */时会把注释整体当作一个token;括号、逗号、分号则识别为符号。普通单词会一直读取到空白、括号、逗号、分号或单引号为止。

把SQL拆成token之后,格式化器就不再面对原始字符串,而是面对带有类型的信息流。此时字符串里的select不会被当成关键字,注释里的FROM也不会触发换行。这是后续实现嵌套缩进的基础,也让整个工具的行为更加可预测。

三、括号深度与缩进:让子查询自动换行

有了token流,就可以在遍历时维护一个括号深度。每当遇到左括号,深度加一;遇到右括号,深度减一。缩进使用两个空格乘以当前深度。对于SELECT、FROM、WHERE、JOIN、ON等顶层关键字,在它们之前插入换行,并按当前深度补齐缩进。

func formatTokens(tokens []token) string {
    var b strings.Builder
    depth := 0
    newLine := false
    topKw := map[string]bool{
        "SELECT": true, "FROM": true, "WHERE": true, "GROUP": true,
        "ORDER": true, "HAVING": true, "LIMIT": true, "OFFSET": true,
        "JOIN": true, "LEFT": true, "RIGHT": true, "INNER": true,
        "OUTER": true, "ON": true, "UNION": true, "AND": true,
        "OR": true,
    }

    for _, tk := range tokens {
        switch tk.typ {
        case tokString:
            if newLine {
                b.WriteString(strings.Repeat("  ", depth))
                newLine = false
            }
            b.WriteString(tk.val)
            b.WriteByte(' ')
        case tokComment:
            if !newLine {
                b.WriteString(" ")
            } else {
                b.WriteString(strings.Repeat("  ", depth))
                newLine = false
            }
            b.WriteString(tk.val)
            b.WriteByte('\n')
            newLine = true
        case tokSymbol:
            switch tk.val {
            case "(":
                b.WriteString(" (")
                depth++
                newLine = true
            case ")":
                depth--
                if !newLine {
                    b.WriteByte('\n')
                    b.WriteString(strings.Repeat("  ", depth))
                } else {
                    b.WriteString(strings.Repeat("  ", depth))
                }
                b.WriteString(")")
                newLine = true
            case ",":
                b.WriteString(",")
                newLine = true
            case ";":
                b.WriteString(";")
                newLine = true
            }
        default:
            upper := strings.ToUpper(tk.val)
            if topKw[upper] {
                if !newLine {
                    b.WriteByte('\n')
                }
                b.WriteString(strings.Repeat("  ", depth))
                b.WriteString(upper)
                b.WriteByte(' ')
                newLine = false
            } else {
                if newLine {
                    b.WriteString(strings.Repeat("  ", depth))
                    newLine = false
                }
                b.WriteString(tk.val)
                b.WriteByte(' ')
            }
        }
    }
    return strings.TrimSpace(b.String())
}

这个函数的关键点在于:字符串和注释不会触发新的换行,只会按需要补缩进;左括号会把深度加一,并让后续内容从新行开始;右括号会先降低深度,再输出右括号本身。这样如果一个子查询位于括号中,它内部的SELECT会自动比外层多一级缩进。

例如select u.id from users u where u.status=1 and (select count(*) from logs l where l.user_id=u.id)>3 order by u.id desc,会被格式化成SELECT、FROM、WHERE各自成行,子查询的括号内容再缩进一层。虽然这个规则还比较简单,但已经能覆盖大量日常查询。

把上面的lexer和formatTokens组装起来,可以得到一个可调用的入口:

func Pretty(sql string) string {
    lexer := &lexer{src: []rune(sql)}
    var tokens []token
    for {
        tk, ok := lexer.next()
        if !ok {
            break
        }
        tokens = append(tokens, tk)
    }
    return formatTokens(tokens)
}

需要说明的是,这段代码把AND和OR也放进了topKw,因此在WHERE条件较长时,它们同样会换行。这种风格比较接近常见SQL美化工具的输出,但如果希望条件运算符紧跟上一行,也可以将AND、OR从topKw中移除,改成逗号一样的行尾处理。

四、实用扩展与边界处理

真实项目中的SQL往往比示例复杂得多。CTE的WITH子句、CASE WHEN表达式、多重JOIN和UNION都会增加格式化难度。对于CTE,可以把WITH加入关键字集合,让每个CTE定义独占一行;对于CASE,可以将WHEN、THEN、ELSE、END也纳入关键字,但要注意缩进级别是否需要单独增加。

注释保留是另一个容易忽略的问题。生产环境中的SQL经常带有标注,例如解释查询意图、标明修改人,或者提示某个索引的使用原因。格式化工具不能因为美化就丢弃这些信息。上面的实现会保留行注释和块注释,并尽量让注释出现在独立行中。如果注释原本在代码行尾部,格式化后可能被调整到新的一行,但只要内容完整,可读性通常不会下降。

多语句边界也需要处理。对于包含分号的批量SQL,分号后应强制换行,并把缩进重置为零。字符串里的分号因为已经被识别为字符串token,所以不会触发这个逻辑。类似地,括号内的逗号会换行,而函数参数中的逗号是否换行取决于具体风格。如果不想让COUNT(a, b)这类短函数调用被拆散,可以增加一个判断:只有括号深度大于零且括号内token数量较多时才在逗号后换行。

性能方面,使用strings.Builder已经能避免反复拼接字符串带来的内存分配。对于日志和调试场景,这样的格式化器足够快,不必引入解析器级别的完整语法树。相比之下,完整的SQL解析库虽然能提供更精确的结构信息,但依赖和复杂度会明显增加。如果只是做日志美化、慢查询展示或控制台输出,纯标准库实现是更轻量的选择。

不同的数据库方言也可能带来新的边界条件,例如MySQL的反引号标识符、PostgreSQL的美元引用字符串、SQL Server的方括号标识符。本文的lexer可以根据需要扩展,关键原则不变:先识别出完整字符串和注释,再处理结构符号,最后才判断关键字。这个顺序保证了格式化器不会破坏SQL语义,也让后续增加新规则更加安全。

总的来说,从简单关键字换行升级到词法分析和括号深度控制,投入的代码量并不大,但能显著提升输出稳定性。对于需要经常阅读动态拼接SQL的场景,一个可控的格式化器比盲目引入重依赖更实用,也更容易根据团队风格调整缩进、关键字大小写和注释策略。

Go语言SQL语句格式化嵌套结构缩进修改时间:2026-10-02 00:01:27

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/1002/64431.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。