一条上百行的SQL查询语句挤在字符串里,关键字大小写混乱,JOIN和WHERE挤在一起,排查问题的时候恨不得逐个字符去找条件。这种情况在实际项目中非常普遍,尤其是从日志里捞出来的SQL,或者ORM生成的长语句。把SQL格式化成结构清晰的形式,关键字统一大写、主要子句换行、条件缩进对齐,能大幅提升阅读效率。本文围绕Go语言展开,讲清楚SQL格式化的核心实现思路,并给出可以直接落地的代码。

SQL格式化到底要处理哪些东西
很多人以为格式化就是把关键字换行,实际上要做的事情比这多。一个合格的SQL格式化器至少要处理四类问题:第一是关键字的识别与大写统一,比如select、SELECT、Select都要归一成SELECT;第二是子句级别的换行,SELECT、FROM、WHERE、GROUP BY、ORDER BY、LIMIT这些主要子句各自独占一行;第三是缩进控制,子查询要比外层查询多一级缩进,AND、OR等连接词要有层次感;第四是字符串字面量的保护,这是最容易出错的地方。
为什么字符串字面量需要特别保护?因为SQL里的字符串内容是任意的,用户的备注字段里完全可能写着I love select * from这种文本,如果不小心把字符串内部的内容也当成关键字处理,格式化结果就直接损坏了。类似的还有反引号包裹的标识符、括号内的嵌套结构、注释语句,这些都需要在词法分析阶段识别出来,标记为不可改写的token。
先看一个格式化前后的对比,感受一下效果差异:
-- 格式化前 select u.id,u.name,count(o.id) as order_count from users u left join orders o on u.id=o.user_id where u.status=1 and u.created_at>'2024-01-01' group by u.id,u.name having count(o.id)>10 order by order_count desc limit 20 -- 格式化后 SELECT u.id, u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.status = 1 AND u.created_at > '2024-01-01' GROUP BY u.id, u.name HAVING COUNT(o.id) > 10 ORDER BY order_count DESC LIMIT 20
Go语言实现:词法切分与关键字识别
实现的核心思路是先把SQL切分成token流,再根据token类型决定换行和缩进策略。Go标准库提供了strings和unicode相关工具,足够支撑一个轻量级的切分器。切分时需要逐字符扫描,遇到单引号就进入字符串模式直到匹配到结束引号,遇到反引号同理,遇到双横线则跳过整行注释。
下面是一个简化版的tokenizer实现,展示了如何正确处理字符串边界:
package sqlfmt
import (
"strings"
"unicode"
)
type Token struct {
Value string
IsKeyword bool
IsString bool
}
// 关键字集合,格式化时统一转大写
var keywords = map[string]bool{
"SELECT": true, "FROM": true, "WHERE": true,
"JOIN": true, "LEFT": true, "RIGHT": true,
"INNER": true, "OUTER": true, "ON": true,
"GROUP": true, "BY": true, "ORDER": true,
"HAVING": true, "LIMIT": true, "OFFSET": true,
"AND": true, "OR": true, "NOT": true,
"AS": true, "INSERT": true, "INTO": true,
"VALUES": true, "UPDATE": true, "SET": true,
"DELETE": true, "UNION": true, "DISTINCT": true,
}
// Tokenize将SQL切分为token序列,字符串内容保持原样
func Tokenize(sql string) []Token {
var tokens []Token
var buf strings.Builder
i := 0
flush := func() {
if buf.Len() == 0 {
return
}
word := buf.String()
buf.Reset()
tokens = append(tokens, Token{
Value: word,
IsKeyword: keywords[strings.ToUpper(word)],
})
}
for i < len(sql) {
ch := sql[i]
switch {
case ch == '\'' || ch == '`':
// 字符串或反引号标识符,整体读取到闭合引号
flush()
quote := ch
j := i + 1
for j < len(sql) {
if sql[j] == quote {
// 处理转义的引号,如'it''s'
if j+1 < len(sql) && sql[j+1] == quote {
j += 2
continue
}
break
}
j++
}
tokens = append(tokens, Token{
Value: sql[i : j+1],
IsString: true,
})
i = j + 1
case unicode.IsSpace(rune(ch)):
flush()
i++
default:
buf.WriteByte(ch)
i++
}
}
flush()
return tokens
}这段代码的关键在于引号处理分支:扫描到引号后直接定位到闭合位置,把整段内容作为一个IsString为真的token输出,后续格式化逻辑跳过它即可。同时处理了SQL中双写引号表示转义的语法,这是很多简陋实现会踩的坑。
排版策略:换行、缩进与括号深度
拿到token流之后,排版逻辑就是根据关键字类型和括号深度来决定输出格式。核心规则可以归纳为:遇到主要子句关键字换行并降低一级缩进;遇到逗号在字段列表中换行;遇到左括号深度加一,遇到右括号深度减一;子查询内容整体比外层多缩进一层。用一个状态机循环处理token流就能实现。
package sqlfmt
import (
"strings"
)
// 主要子句关键字,出现时换行顶格到当前缩进层
var clauseKeywords = map[string]bool{
"SELECT": true, "FROM": true, "WHERE": true,
"GROUP BY": true, "ORDER BY": true, "HAVING": true,
"LIMIT": true, "OFFSET": true, "SET": true, "VALUES": true,
}
// Format接收原始SQL,返回格式化后的SQL
func Format(sql string, indentSize int) string {
tokens := Tokenize(sql)
var out strings.Builder
depth := 0
indent := func() {
out.WriteString(strings.Repeat(" ", depth*indentSize))
}
newline := func() {
out.WriteString("\n")
indent()
}
for i, tk := range tokens {
upper := strings.ToUpper(tk.Value)
if tk.IsKeyword && clauseKeywords[upper] {
newline()
out.WriteString(upper)
continue
}
if tk.IsKeyword {
out.WriteString(" " + upper + " ")
continue
}
switch tk.Value {
case ",":
out.WriteString(",")
// 逗号后换行,但只在SELECT字段列表场景下比较合理,
// 简单实现统一换行,实际项目可按上下文细分
newline()
case "(":
out.WriteString(" " + tk.Value)
depth++
case ")":
depth--
if depth < 0 {
depth = 0
}
out.WriteString(tk.Value + " ")
default:
// 普通标识符或字符串原样输出,避免多余空格
if i > 0 && !strings.HasSuffix(out.String(), " ") &&
!strings.HasSuffix(out.String(), "(") {
out.WriteString(" ")
}
out.WriteString(tk.Value)
}
}
return strings.TrimSpace(out.String())
}这个实现是教学性质的简化版,能覆盖大部分日常场景,但距离生产级还有距离。比如GROUP BY和ORDER BY是两个token,需要向前看合并处理;JOIN链的ON条件是否换行、子查询内是否进一步格式化,都需要更精细的上下文判断。如果是正式项目,建议直接使用成熟的开源库,例如sqlparser类的AST解析器,先解析成语法树再从树节点生成格式化文本,这样格式化结果永远不会破坏SQL语义。基于字符串切分的方案优点是轻量、无依赖,适合日志美化、控制台输出这类只读场景;基于AST的方案适合对正确性要求高的场景,比如SQL审计平台、慢查询分析工具。
避开常见的坑:从字符串保护到方言差异
自己实现格式化时,有几个高频踩坑点需要提前知晓。第一是注释处理,双横线行注释和斜杠星块注释内部的内容绝不能参与关键字识别,块注释还可能跨越多行,切分器必须完整消费。第二是不同数据库的方言差异,MySQL支持反引号标识符,PostgreSQL用双引号,SQL Server用方括号,格式化器如果硬编码一种引号规则,遇到其他方言就会解析错乱。比较稳妥的做法是给Format函数加一个方言参数,按方言选择不同的引号处理策略。
第三是函数调用的排版,COUNT(u.id)如果被拆成COUNT (u.id)会影响可读性,识别出前一个token是函数名时,左括号前不应加空格。第四是性能问题,如果场景是对大量慢日志做批量格式化,注意复用tokenizer,避免频繁的字符串拼接产生内存压力,可以用预分配容量的strings.Builder,或者考虑用bytes.Buffer减少分配次数。
最后给一个实际调用的例子,展示如何把格式化能力接入日志中间件:
func main() {
raw := "select id,name from users where age>18 and city='hangzhou' order by id desc"
pretty := sqlfmt.Format(raw, 2)
fmt.Println(pretty)
// 输出:
// SELECT id, name
// FROM users
// WHERE age > 18 AND city='hangzhou'
// ORDER BY id DESC
}总结一下,Go实现SQL格式化的本质是词法切分加排版规则两步走,字符串和注释的保护是正确性的底线,方言适配和上下文判断决定了格式化效果的上限。轻量场景自己写百来行代码即可满足需求,重场景则优先考虑基于语法树的开源方案,两者结合使用往往是最务实的选择。