尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

编译原理实战(四):把编译器技术用到业务里——SQL 透明分片路由与报表模板引擎

编译原理实战(四):把编译器技术用到业务里——SQL 透明分片路由与报表模板引擎 编译原理实战四把编译器技术用到业务里——SQL 透明分片路由与报表模板引擎系列博客《编译原理实战》第四篇。前三篇我们分别手写词法分析器、递归下降语法分析器并用它做了四则运算计算器。这一篇脱离“玩具”把它真正落到两个业务场景分库分表下的 SQL 透明路由以及一个迷你报表模板引擎。所有代码均已在华为云 ECSUbuntu 24.04 / Python 3.12真实运行输出无编造。一、引言编译技术为什么能落地到业务很多同学觉得编译原理是“造语言”的学问离业务很远。其实不然。凡是“把一种文本按规则变成另一种结构再据此产生新文本/新行为”的地方都是编译器的工作现场你写的SELECT ...是一条 DSL领域特定语言数据库要先把她解析成执行计划——这就是一个编译器前端分库分表中间件ShardingSphere、TDDL要在 SQL 执行前把它改写成指向物理表的语句——这是“解析 代码生成反解析”报表系统里的{{var}}模板本质上也要被编译成可执行的渲染函数。本篇我们就亲手实现前两者并顺带用一个极简“动态代理”点一下字节码生成思想。你会看到前端词法/语法分析、中端语义提取、后端代码生成这三段式流水线与工业级中间件是同构的。二、核心概念速览概念在本篇的对应物编译原理里的角色Token 流sql_lexer.py的输出词法分析LexerASTSelect/Update/...节点语法分析Parser语义提取从WHERE里抠出分片键user_id中端语义分析代码生成to_sql()反解析 物理表替换后端Code Generation模板 ASTText/Var/For/If节点模板语言的前端渲染函数exec生成的render(ctx)模板语言的“后端”两个应用共享同一条流水线文本 → AST → 提取信息 / 生成目标。三、应用一SQL 解析器 分库分表透明路由3.1 词法分析把 SQL 切成 Token手写 Lexer 核心是一个扫描指针按“当前字符该归哪一类”前进deflex(sql):tokens[]i,n0,len(sql)whilein:csql[i]ifcin \t\r\n:# 1) 跳过空白i1;continueif_is_ident_start(c):# 2) 标识符 / 保留字...ifc:# 3) 单引号字符串...ifcin!:# 4) 多字符运算符优先 ...ifcin-/%:# 5) 算术运算符...ifcin(),.*;:# 6) 标点含 * 通配符...tokens.append(Token(EOF,,i))returntokens踩坑提示最初我把*放进了运算符集合-*/%导致SELECT *被识别成运算符而非通配符解析直接报“期望 IDENT”。修正为把*归入标点即可。3.2 语法分析递归下降产出 ASTWHERE 的文法用“优先级金字塔”表达最低优先级是OR最高是比较orExpr - andExpr (OR andExpr)* andExpr - notExpr (AND notExpr)* notExpr - NOT notExpr | comparison comparison- operand (OP| ...) operand对应到 Python 就是一组互相调用的parse_or / parse_and / parse_not / parse_comparison。最终得到一棵 AST例如Select( columns[Column(nameid), Column(namename)], tableorders, whereAnd( leftCompare(op, leftColumn(nameuser_id), rightLiteral(3)), rightCompare(op, leftColumn(nameamount), rightLiteral(100))))3.3 路由层提取分片键 → 计算分片 → 改写 SQL路由是“中端 后端”的合体。先语义提取在WHERE的 AST 里递归找user_id 字面量defextract_shard_value(where,shard_key):ifwhereisNone:returnNoneifwhere.kindCompare:leftwhere[left]ifleft.kindColumnandleft[name]shard_keyandwhere[op]:rightwhere[right]ifright.kindLiteral:returnright[value]# 拿到分片键值returnNoneifwhere.kindin(And,Or):# 在 AND/OR 树下继续找forsidein(left,right):vextract_shard_value(where[side],shard_key)ifvisnotNone:returnvreturnNone...再计算分片。这里特意用md5做稳定哈希避免 Python 默认的hash()带进程随机种子PYTHONHASHSEED否则每次跑路由结果都变不利于演示与线上排查def_stable_hash(s):returnint(hashlib.md5(s.encode(utf-8)).hexdigest(),16)def_shard_index(key_value,num_shards,algorithm):ifalgorithmmod:returnint(key_value)%num_shardsifisinstance(key_value,(int,float))\else_stable_hash(str(key_value))%num_shardsreturn_stable_hash(str(key_value))%num_shards# 默认 hash分布更均衡最后代码生成用 AST 反解析to_sql并把逻辑表orders替换成物理表orders_{idx}业务层全程只写逻辑表完全无感。四、应用二报表模板 DSL 编译器我们设计一套迷你模板语言支持{{ var }} 变量插值支持点号 r.name {% for x in list %} ... {% endfor %} 循环 {% if cond %} ... {% else %} ... {% endif %} 条件它的编译流程更“刺激”不是边解释边拼字符串而是把模板直接编译成一段 Python 源码render(ctx)再exec成可复用函数——这正是 Jinja2、Thymeleaf 的核心思想模板只编译一次之后每次渲染都跑原生字节码。模板 AST 解析后代码生成器把每个节点翻译成语句。例如{% for r in regions %}会被翻译成_ns[r]Noneforrin_r(regions):_ns[r]r# 把循环变量注入命名空间内层 {{ r.name }} 才找得到_out.append(\n区域)_out.append(str(_r(r.name)))其中_r(expr)是把模板表达式当 Python 表达式eval命名空间_ns同时装着上下文变量和循环变量所以total 100000、r.sales这种比较与点号访问都能直接求值。五、真实运行效果华为云 m4 实跑5.1 分库分表路由$cd/root/compiler/apppython3 examples/demo_sql.py# 2) 分库分表路由逻辑表 orders 分 4 片分片键 user_iduser_id1-分片下标3-物理表 orders_3 改写SQL: SELECT id, user_id, amount FROM orders_3 WHERE(user_id1AND amount100)user_id2-分片下标0-物理表 orders_0 改写SQL: SELECT id, user_id, amount FROM orders_0 WHERE(user_id2AND amount100)user_id3-分片下标3-物理表 orders_3 改写SQL: SELECT id, user_id, amount FROM orders_3 WHERE(user_id3AND amount100)同一张逻辑表orders按user_id的哈希值透明落到了不同物理表——上层业务代码完全不必知道orders_0..orders_3的存在。# 3) 不同分片算法对比user_id 取 1..8user_id|hash%4|mod4------------------------------1|3|12|0|23|3|34|0|05|1|16|0|27|3|38|1|0可见hash比mod分布更均匀避免了连续user_id扎堆到同一片。# 4) INSERT / UPDATE / DELETE 同样可路由逻辑SQL: INSERT INTO orders(user_id, amount)VALUES(7,199.5)路由到: orders_3 改写SQL: INSERT INTO orders_3(user_id, amount)VALUES(7,199.5)逻辑SQL: UPDATE orders SET amount200WHERE user_id7路由到: orders_3 改写SQL: UPDATE orders_3 SET amount200WHERE user_id7逻辑SQL: DELETE FROM orders WHERE user_id7路由到: orders_3 改写SQL: DELETE FROM orders_3 WHERE user_id7# 5) 未带分片键 - 无法路由需广播所有分片结果: 未找到分片键 user_id 的条件无法路由需广播到所有分片注意 INSERT 的分片键写在VALUES里而非WHERE路由层专门做了列/值对齐提取而缺失分片键的查询只能广播到全部分片——这正是分库分表设计里“必须带分片键”的根本原因。5.2 报表模板渲染$ python3 examples/demo_template.py[渲染结果]极客制造 销售月报2026-07本月总销售额128000 元 达标状态优秀 --- 各区域明细 --- 区域华东 销售额62000 元 同比12.5%[★ 重点区域]区域华北 销售额38000 元 同比8.1% 区域华南 销售额28000 元 同比-3.2%{% if total 100000 %}命中“优秀”{% if r.sales 50000 %}只给华东打上“★ 重点区域”——条件和循环都正确生效。5.3 可选动态代理字节码生成$ python3 examples/proxy_demo.py 直接调用经过代理[代理]调用前 -create_user((alice,),{})[代理]调用后 -返回user:alice耗时10.08ms[代理]调用前 -delete_user((42,),{})[代理]调用后 -返回deleted:42耗时0.01ms 生成的代理类 UserServiceProxy 继承于(class__main__.UserService,)这里用type()在运行时动态生成一个子类对目标方法织入日志与计时思想与 Spring AOP / CGLIB“运行时生成子类”一致——只不过工业级方案生成的是 JVM 字节码我们这里生成的是 Python 类对象。六、难点解析1. 路由的“透明性”如何保证透明性来自“解析—改写”闭环业务层写逻辑 SQL路由层在真正下发数据库之前才把orders换成orders_N。只要 WHERE 带了分片键路由就能 100% 命中单片没带则退化为广播。透明不等于万能缺分片键的查询代价很高这是分片设计必须前置约束的。2. 模板的作用域问题模板里for的循环变量r要在其内部的{{ r.name }}可见本质是变量作用域。我们的做法是把循环变量写进渲染函数的局部命名空间_ns再用eval(expr, ..., _ns)求值天然支持嵌套作用域。这也是 Jinja2 用“环境/上下文”对象管理作用域的思路。3. 手写 vs ANTLR本篇主线全部手写好处是零依赖、逻辑透明、易调试代价是每加一个语法都要自己维护递归函数。工业项目更常用ANTLR这类工具用.g4声明式描述文法examples/SqlSimple.g4一条命令生成解析器与语法树。手写适合学习和可控场景ANTLR 适合快速支撑复杂语法——理念完全一致只是轮子换成了生成器。七、小结我们用不到 600 行纯 Python把编译器三段式流水线用在了两个真实业务里SQL 透明分片路由词法 → AST → 提取分片键 → 哈希取模 → 反解析改写业务无感地落库到orders_0..orders_3报表模板引擎模板解析成 AST → 编译成render(ctx)函数 → 高效渲染演示了“编译而非解释”的模板思想动态代理用type()运行时生成子类点出字节码生成在 AOP 里的影子。编译原理不是屠龙术。当你下次面对“规则化的文本变换”需求——配置 DSL、规则引擎、SQL 改写、模板系统——不妨先问一句这能不能拆成“解析 → 语义 → 生成”三步往往一行手写递归下降就顶得上一堆正则与字符串替换的脆弱代码。完整源码见sql_lexer.py / sql_parser.py / sharding.py / template.py / examples/*bash run_all.sh可一键复现本文全部输出已保存在output.txt。
返回列表