Skip to content

gendry 2的一些想法

Deen edited this page Apr 25, 2020 · 1 revision

最近由于用户们提出了很多新的feature request, 而我在开发这些新feature时发现, 在现有的接口和抽象上, 支持那些看起来很合理的新需求已经变得比较复杂了. 想来想去想到一种方法, 又觉得比较丑陋. 因此下定决心对gendry/builder的代码进行一次重构. 在重构之前, 我也想来聊一聊最初为什么这么设计,重构时需要注意哪些问题, 以及这之中的经验教训

一开始的初衷

一开始gendry只是想解决业务中的一些常见sql的生成. 比如在我们的业务系统中, 最常见的就是select update insert delete这四种增删改查的sql语句, 也就是常说的curd. 并且由于业务数据量比较大, 做了分库分表, 所以基本上不太可能有join, join逻辑都在业务代码中完成. 因此, 可以说我们99%的sql都是非常简单的curd语句. 根据二八法则, 我们只需要非常简单的API, 就能覆盖到绝大部分的业务场景. 因此, 我开发了gendry. 基于这个背景, 在设计时主要有以下几个核心原则:

  • 只支持简单curd, 不追求覆盖所有场景(二八法则).
  • 接口要直观, 几乎所见即所得, 不要给用户"惊喜".

先说说对第一个原则的思考. 由于我们目标就是支持简单curd, 不覆盖也不追求覆盖所有场景, 那么用户在使用时必然会经常遇到gendry不支持的sql. 因此, 我们最好的选择就是把自己变成一个"静态工具"包,就像标准库的strings或者filepath一样, 不强制你用, 你用的上就用, 用不上就自己解决. 而Go中和数据库打交道最重要的就是sql.DB对象, 通过它来执行sql语句. 因此我们只负责生成sql即可, 这样即使遇到我们生成不了的sql, 对于用户来说, 事情不会变得更糟. 因为不用gendry时他需要手写sql, 用了gendry, 他绝大部分时候不需要手写了, 需要时自己再写. 这也是最重要的非侵入式设计.从某种意义上说, gendry永远不会成为你项目开发的阻碍

对于第二个原则, "直观"是个见仁见智的词语. 对于我来说, 直观有两层含义:

  • sql本身要简单. 这个显而易见, 复杂了肯定就不直观了. 你觉得又是子查询又是join还得靠缩进才能看得出结构的sql语句能有多直观?
  • 所见即所得, 看一眼就知道即将生成的sql长什么样.

当谈到所见即所得你会发现, 没有什么方式比直接裸写sql更所见即所得. 尤其是基于大部分sql还是很简单的sql这么一个事实. 这看起来就比较矛盾了.因为如果大部分sql都很简单,这种情况下写sql最直观, 那gendry存在的意义是什么?

所以我们需要回归到本质问题. 即为什么用户在写sql时需要一个lib的帮助? 他到底是需要lib帮他做哪些事?说到底还是降低开发的负担. 因为我们也说了,没有什么比裸写sql更直观了. 因此一个lib就只能在尽量保持直观的基础上, 让用户写代码时能够少打字. 这就是它的意义. 如果封装过多抽象程度太高, 那有点和解决问题的初衷背道而驰. 那么对于写sql来说, 开发的负担是啥呢? 我认为有以下两个:

  • 拼字符串. 尤其是对于where in查询, in的内容是动态的, 拼起来比较麻烦
  • 查询条件不固定. 比如查询条件随请求入参变化而变化, 实际上会有非常多种sql

以上两条是最核心的诉求. 并且, 第二个诉求出现的场景也比较受限. 大部分的需求就是简单sql但是包含了where in的动态查询. 因此, 为了解决这个问题, gendry设计了NamedQuery. NamedQuery一方面能够根据in的value自动生成对应的(?,?,...), 同时它还有一个好处就是, 它可以避免一些低级错误. 比如以前有人这么写sql:

fmt.Sprintf("select * from tb where name=%s and phone=%s and city in (%s)", phone, name, expand(city)) // name和phone的位置写反了!!!

你在写sql时必须很小心地肉眼来回不停扫描, 以确保参数以正确的顺序传给了Sprintf, 稍不留神可能就出错. 而NamedQuery要求你把参数包起来:

NamedQuery("select * from tb where name={name} and phone={phone} and city in {city}", map[string]interface{}{
  "name": name,
  "phone": cellPhone,
  "city": user.Cities,
})

在sql部分直观程度和裸写sql几乎一样,但是传参时更加一目了然, 并且能够自动处理where in expand, 相较于手写SQL是一个巨大的进步. 有了NamedQuery, 其实对于我个人来说, 绝大部分的sql查询都推荐用它. 因为:

  • 直观
  • 支持所有sql, 包括复杂查询, 因为这本质上就是sql + expand + 优化传参方式

但其实仅仅NamedQuery是不够了, 因为我们开发中还有第二个诉求, 即动态sql. 除了值是动态的, 条件也可能是动态的. 比如当要为某个表实现一个通用查询接口时, 它需要支持各种各样的查询条件. 举例来说, 对于User表, 可能有些接口要通过ID去查, 有些接口通过name去查, 还有些通过phone查询等等. 针对一个表的查询方式有非常多种, 如果涉及到多个表, 你会发现其实都是一样的. 如果都用NamedQuery, 可能需要写很多非常类似的重复代码. 而仔细分析可以发现, 这些简单查询其实就只有3个部分是变化的:

  • 表名
  • 查询条件以及对应值
  • select字段

这3个部分一起, 就能准确地表示一个简单sql语句. 这就是gendry提供的第二类接口, BuildSelect. 我们把查询字段用map[string]interface{}来表示:

where := map[string]interface{}{
  "age >": 10,
  "city in": []string{"Beijing", "London"},
}

map的key不仅是字段名, 还包括字段的比较符号. 通过这种方式, 用户不仅能够支持动态条件的sql, 同时也能非常直观地表达sql. 几乎所见即所得. 同时, group by 和 limit等也是非常常用的语素, 因此也通过特别的方式进行了支持

设计时的一些考虑

在开发gendry时最大的一个挑战其实是单元测试. 我必须要写单元测试来assert BuildSelect输出的sql语句, 即:

var testCases = []struct {
  where map[string]interface{},
  expectSQL string
}

但是由于我们的API传入的是一个map, 而对map的遍历是无序的, 如果不加处理, 可能每次生成的sql都不一样. 这样我根本就没办法写单测, 同时这种看起来stateless的方法对同样的输入产生不同的输出在生产环境中有时也是不能接受的.

所以第一个问题就是要在内部处理顺序, 保证生成的where顺序是一致的. 要保证一致, 最简单的办法就是对key进行字符串排序. 但是考虑到sql具体的执行, where语句的顺序虽然不影响索引的选择, 但是当索引查询完回表时, 还需要对非索引字段进行匹配. 而sql的解析顺序是从右到左的, 因此把能让结果集变得尽量小的条件放到越右边越好. 但是到底哪些字段能够快速收敛,不同的表是完全不一样的, 你必须了解业务才能做这个决定. 但是通常来说, 等于比较的收敛速度都大于其它,基于这个考虑, 我需要对比较条件进行分类, 然后把等于之类的能够让结果集大小快速收敛的条件放到右边. 所以总的来说, 排序方法是, 先对比较符排序, 对于相同的比较符号, 再对col_name排序.

经过这部处理之后, 我们得到了如下的数据结构:

OrderedMap<Comparator, OrderedMap<String, Object>>

此时, 我们可以发现, 对于每一个OrderedMap, 它们都有一个相同的比较符号. 顺其自然地, 我可以抽象出一个interface, 让每种不同的比较符号实现这个interface, 从而完成对应的字符串拼接. 比如:

Like{}.Build(map["like"]) == "foo like ? and bar like ?"
Equal{}.Build(map["="]) == "foo=? and bar=?"
In{}.Build(map["in"]) == "foo in (?,?,?) and bar in (?)"

所以最终的处理流程伪代码如下:

var data OrderedMap<Comparator, OrderedMap<String, Object>> = sort(where)
var cond []string
var vals []interface{}
for cmp, orderedMap in data {
  builder := newBuilder(cmp)
  c, v := builder.Build(orderedMap)
  cond = append(cond, c)
  vals = append(vals, v)
}

另一个值得一说的地方就是sort. 前面也说了, sort时需要先对comparator分类, 然后对每个类别分别进行排序. 但问题是我们的where是map[string]interface{}, key虽然是string, 但是它是col_name + comparator("age > "), 也就是说我需要先从key中分离出col_name和comparator. 可问题也接踵而至, 如果key只是简单的col_name + comparator那非常好办, 但是很多时候用户可能需要比如{"money-10 >": 10}或者{"rand() >": xx}. 如果要支持这样的sql语句, 虽然我依然可以分离出comparator, 但是col_name怎么办? 如果这里不进行一些语法分析, 那就很难获得col_name! 在这里做语法分析其实也很麻烦, 性能损耗极大, 且感觉杀鸡用牛刀. 那么在不进行词法和语法分析的情况下, 这里就有一个艰难的选择:

  • 如果要支持"money-10 >"这类key, 就没法正确分离出col_name, 意味着生成的sql中col_name无法加上`col_name`
  • 如果要为col_name加上`, 那必须要能正确分离出col_name, 意味着"func(func(col_name)) >"这类需求就无法支持

为了功能更多, 我选择了前者, 因为即使col_name不被包围在`中依然是没有任何问题的---除非col_name和sql关键字冲突. 而且当关键字冲突时, 用户依然可以使用NamedQuery解决

一些问题

但是随着时间推移, 有少部分用户抱怨sql语句报错, 因为col_name和数据库关键字冲突. 这其实是预期之中的结果, 因为这就是选择支持更多灵活性带来的负面效果. 如果找不到一种方式正确地分离出col_name, 那么这个问题无解. 要么加上`而缺失灵活性, 要么为了灵活性只能提前在文档上注明用户需要避免使用和sql关键字冲突的字段名, 或者让用户自己加上`, 比如:

{"`name`": "caibirdme", "`select`": "football"}

之前向用户们承诺会在本周进行一个发版支持上`col_name`, 一开始的想法是通过新加一个方法比如BuildSelectQuoted, 这个方法生成的sql就是可以用`col_name`来避免关键字冲突. 但是, 思来想去发现问题并不是这么简单. 本质上这是一个breaking change. gendry和其它包不同的是, gendry的API除了exported的有限几个方法, 它还包括了能够支持的where的语法. 也就是说where = map[string]interface{}中,这个string你能怎么写, 也是API的一部分. 如果支持了Quoted col_name, 相当于取消了对select count(foo) as total那一类功能的支持. 这会给lib带来很大的不一致性! 它不是一个Enhancement, 而是对之前tradeoff的一种推翻.

因此我们需要对此非常谨慎, 这个功能可能会让gendry bump to v2.0

一些反思

与此同时我也趁此机会进行了一些反思, 随着这些年自己对软件工程理解的加深, 也发现了项目中一开始设计得不那么合适做得不够好的地方

第一个问题就是无效优化. 在不知道具体业务的前提下, 对where条件进行重排真的会提高查询速度吗? 事实上查询速度完全是和索引相关, 是否使用索引, 是否需要回表, 排序是否使用索引...我在网上进行了很多搜索, 并没有发现直接证据证明where条件的顺序会影响查询速度. 因此, 对比较符进行重排基本上可以说是一个过度且无效的优化, 它增大了实现复杂度却并没有任何收益

第二个问题是边界. gendry的BuildXXX系列方法应该是简单的, 按照二八法则做最简单而又通用的事情. 支持rand() > 5或者select count(price) as total_price where ...这种查询算是通用吗? 我觉得不算, 因为这类查询都是特殊的查询, 并不是简单组合几个where条件. 这意味着用户在实际调用gendry时, 一定是手动编写的key, 而不是从参数里自动取的. 如果已经需要手动编写key, 那么我觉得就应该用NamedQuery.对于函数的边界, 一定要保持克制, 否则会变成四不像

另一个问题是代码实现层面的问题. Gendry提供的是一系列无状态的纯函数, 在函数内部并没有任何状态, 都是通过组合各种子函数来实现最终的功能. 这没有什么问题, 对于习惯于函数式编程的人来说这再正常不过了. 但是我发现, 一旦你使用了纯函数, 当你定义好你函数的签名之后, 它就永远不能变了, 因为已经有用户开始在使用这个API了. 你的整个代码逻辑相当于f(x,y) = s(t(g(x,y))). 在你写完f(x,y)的函数定义之后, 相当于你已经确定了f只依赖于x和y 2个外部参数, 或者说你只能从外部利用x和y来控制f的行为. 而当你要更新f函数的功能时, 你能做的仅仅是修改s t g等函数内部实现. 这么说可能有点抽象, 还是举例来说, 比如我们BuildSelect函数的定义:

func BuildSelect(table string, where map[string]interface{}, selectField []string) ...

实际上在代码实现内部, 我硬编码了一些逻辑, 比如:

  • col_name周围不加`
  • 对comparator进行排序是按 = in != ..这种顺序来的
  • 生成紧凑的字符串, 即a=?而不是a = ?
  • 生成mysql的placeholder即?而不是pg的placeholder($)

这些逻辑其实也是可以利用参数从外部控制的, 属于可选参数的默认值. 对于Go来说, 它没有可选参数(我觉得这个是一个正确的语言设计), 那么也意味着, 要么我把函数定义变得很复杂, 接口需要N个参数. 它可扩展性很好但是很难用. 要么函数定义保持简单, 直接放弃对某些功能进行扩展的可能性. 但是问题是, 你几乎永远不可能预测到到底你的程序哪些部分需要扩展而哪些部分不用. 而你又不能因为要为扩展留一些可能性而让代码变得非常抽象, 因为大概率不会去扩展. 即使你进行了某些抽象, 留了一些口子, 但是你又怎么能笃定就够了呢? 你预留了对A和B进行扩展的口子, 但最后需要扩展C... 当然, 对于接口的设计实际也没那么麻烦. 具体问题具体分析就好, 把握好二八法则, 想清楚你的目的就行.

不过在这里我想讲一下我对此的思考. 我们不能把目光仅仅局限在函数本身上. 先想一个问题, 如果要从外部控制函数的行为只能通过函数参数吗? 其实并不是, 你完全可以通过全局变量!! 比如:

var extra int = 0;

func add(a,b int) int {
  return a+b+extra
}

当然, 全局变量不是一个好方法, 因为这会导致各种data race以及全局变量带来的各种问题. 不过这给我们提供了一种思路. 我们还有一种更好的办法, 就是对象方法, 比如:

struct Foo{
  extra int
}

func (f *Foo) Add(a,b int) int {
  return a+b+f.extra
}

可以看出虽然Add方法定义是接收两个参数, 但是你可以通过实例化Foo对象来间接控制Add的行为. 假如一个struct A包含k个字段, 同时假如A的Foo方法接收n个参数, 这其实意味着我们一共可以有k+n个参数来控制Foo函数的行为. 或者说struct A的所有方法都多了k个有默认值的入参 这可以给lib的实现带来巨大的好处, 尤其是对扩展.举例来说, 比如我们有个日志函数:

func Log(w io.Writer, content Content) {
  writeLevel(w, content.Level)
  writeTime(w, time.Now())
  writeEvent(w, content.Event)
  // ...
}

如果在无法改变接口签名的情况下想增加一个功能, 支持设置输出到屏幕的字体颜色, 应该怎么办呢?对于以上的实现, 我们只能新增一个函数(这不是问题), 然后改变所有内部函数的签名, 如:

func Log(w io.Writer, content Content) {
  writeLevel(w, content.Level, defaultColor)
  writeTime(w, time.Now(), defaultColor)
  writeEvent(w, content.Event, defaultColor)
  // ...
}

func LogWithColor(w io.Writer, content Content, color Color) {
  writeLevel(w, content.Level, color.Level.Color)
  writeTime(w, time.Now(), defaultColor)
  writeEvent(w, content.Event, color.Event.Color)
}

这不仅需要新增一个API, 还需要改动所有代码, 代价非常大.但是如果用struct方法呢:

type Logger struct{
  color Color // 新增一个字段
}

// Log方法完全不动
func (l *Logger) Log(w io.Writer, content Content) {
  l.writeLevel(w, content.Level)
  l.writeTime(w, time.Now())
  l.writeEvent(w, content.Event)
  // ...
}
// 各自子方法内部根据需要增加对color的支持
func(l *Logger) writeLevel(w io.Writer, lvl Level) {
  // 新增l.color相关逻辑
}

也就是说, 对struct的方法进行扩展基本上是没有成本的, 你只需要新增一个字段, 并且在内部任何需要该字段的地方取到它而不需要修改函数签名, 非常方便! 外部可以在实例化Logger对象时新增对颜色的控制. 当外部无法实例化Logger对象时, 也可以通过新增LogWithColor方法让用户可以修改对象内部状态(但是需要标注非thread safe).

所以, 后续我也推荐大家考虑到可扩展性, 尽量利用成员方法来实现功能, 而不是利用纯函数. 即使是纯函数, 它内部最好也是实例化一个对象然后用该对象的成员方法来完成工作.

最后

gendry的重构也会遵循这些原则. 可能会跳一个大版本, 并且不向后兼容.

Clone this wiki locally