请不要再使用 __file__ 啦!

一般来说,Python 的 module 会有一个 __file__ 属性,定义了 module 的 path。在 Python 中,使用这个属性非常常见,比如获取 module 所在的目录地址,以便于读取这个 module 同级的,非 Python 脚本的其他文件,比如库需要依赖的数据等:module_dir = os.path.abspath(os.path.dirname(__file__)) ;或者用来获取脚本的位置,来进行魔法 import 操作。

但严格来说 __file__ 不总是有的。Python 文档中说:

__file__ 当 module 是从文件中加载的,才会有。如果是静态编译的 C Module,就不会有这个 __file__,extension module 中如果动态链接了 shared lib,那么 __file__ 的值就是动态链接库的位置。

另外,如果你的包是在一个 zip 里面的,__file__ 也没有。

所以,所有使用了 __file__ 的 Python 代码:

  • 要么是 broken 的;
  • 要么选择了不会支持其他的 module loader(不从文件系统 load module)

但是我们经常有读文件的需求,比如图片、json、csv 等,不用 __file__ 的话,怎么知道它们在哪里呢?

如果 Python 版本 < 3.7,解决方案会有点复杂。因为 3.7 之前一直没有一个完美的方案来读取文件资源。有一段时间 pkg_resources 是推荐的方式,Python3 后来引入了 ResourceLoader 接口,然后在 3.7 又 deprecated 了这个接口,并引入了 ResourceReader ,提供了对应的 API importlib.resources module 。即使最近的 ResourceReader,也有一些行为的不一致。但是到目前为止,应该是这些 API 中最好的选择了。

如果你使用 Python3.7+ 的话,可以直接使用内置的 importlib。如果是使用 3.5+ 的 Python 的,建议使用 importlib_resources,这个库是 importlib.resources 的向后兼容版本(实际上,这个库推荐 3.9 之前都使用这个库,3.9 使用标准库)。如果是用 3.5 以下的 Python 的话,应该尽快升级 Python 的版本。

当然了,一些小的脚本,可以不考虑兼容性直接使用 __file__ 的。

可能有人认为 Python 总是用脚本来跑的,可以依赖文件系统的路径,没有什么大不了的。但是我觉得现在 Python 的应用场景越来越多,Python 代码应该考虑运行在各种环境,最好不要对这些环境做一些“假设”。

这些想法起源与最近把 IRedis 打包成一个二进制来跑,希望用户在使用 IRedis 的时候,只要用 cURL 下载下来,然后解压,就可以运行了,不用装 pip,不用使用 pip 安装,甚至不装 Python 或者发行版的 Python 不支持 IRedis 都没问题,因为 IRedis 运行所需要的所有东西都已经包装在这个二进制里面了。在某些情况下是非常有用的,比如说 Redis 的 Docker 镜像,里面是没有 Python 的。

另外打包成 binary 体积也更小,现在用 docker 下载 Python 的 image 将近 1G 了,而打包好的 iredis.tar.gz 才 22M(包含Python解释器)。

这项打包工作是 Mac Chaffee 完成的,他写的打包脚本非常精彩,有兴趣的可以看一下这项工作的记录,很有意思:

Anyway,我们遇到的问题是,很多包都依赖了 __file__ ,去 patch 这些 package 几乎是不可能的:

所以就采用了一个这种的方案,将这些依赖放到一个 lib/ 下,依然让它们作为文件系统的文件存在。虽然这样让打包出来的东西并不是一个纯正的 binary,解压之后会有一个 iredis binary,还有一个 lib/ 目录,但综合考虑可能是性价比的方案了。

如果你正在开发的是一个 lib 的话,强烈建议不要在代码中依赖 __file__ 了。为 Python 更广阔的应用场景做一份贡献!

 

每行80个字符在今天(2020年)依然合理!

很多代码库每行长度最多为 80,这是因为古老的打孔纸的最大长度是 80,一开始的显示器每一行显示的字符也并不是特别多。这一 Max Length = 80 的传统被一直延续下来了。

IBM 打孔纸

现在宽屏显示器非常流行,有一种声音说在代码中将每行的字符限制为 80 是没有必要的,因为这会浪费显示器的空间。

但是我认为行长度设置为 80 依然有道理:

“浪费宽屏显示器的空间” 是不实的。基本上所有的编辑器都有“分屏”的功能,80个字符更有利于分屏展示代码。相反,如果行长度太长,反而会给分屏带来麻烦,比如10行代码中有1行是100多个字符,那么分屏的时候会将这一行“折行”(wrap),显示的很糟糕;如果行长80,可以自由的分屏。

相反,行太长才是“浪费显示器的空间”,屏幕的右边只是偶尔出现几行,大部分都是空白。分屏才能有效利用宽屏显示器。

另外,笔记本的屏幕依然没有“那么大”,如果短行在哪里显示都是OK的,但是长行在小屏幕下显示很糟糕。

Vim 分屏

80字符为一行更容易阅读代码。每行字符更少,分一行来表达一个操作,容易理解。(阮一峰的博客,读起来比较轻松,他比较喜欢用短句,来表达比较复杂的东西,比如你现在在阅读的,括号里面的这段文字)

再比如以下 Java 代码,从 items 中随机选择 5 个。(可能有更简单的方法,这里只是为了举个例子)。

不限制行的长度

增加换行使代码更易读。

80 个字符可以让代码在其他阅读器上阅读更友好。代码并不总是在 Vim 中打开的,比如你躺在床上,打开 ipad,将 more-itertools 的代码放大到和屏幕同样宽度,阅读这些短小巧妙的函数,非常惬意。

用 ipad 在 Github 上阅读代码

当然,死板的遵守 80 个字符也是没有意义的(比如 URL)。Kernel 代码中就有超过 80 的行。Linux 说(2012年的邮件):

有时候打破 80 比强行拆成两行要好。所以代码库中有超过80的情况,这并不是说应该行长100,而是 80+ 在这种情况下是最好的选择。

所以这是一个折中的方案。把 80 想象成是一个 Hard Limit 是不对的,试图将这个 Hard Limit 扩展到 100 也是不对的。

 

综上,一行代码的最大长度依然是 80 !

 

Use the Index, Luke! 笔记2 性能、Join操作

这是读 use-the-index-luke 的第二篇笔记,是 Testing and ScalabilityThe Join Operation 两章的内容。

存储大小和请求量对性能的影响

在开发环境工作很好的 SQL 到了生产环境可能执行的很慢,主要会受两个方面的影响:

  1. 数据量:同样一个查询,在几百条开发数据里面跑和几亿的数据里面跑,效果是不一样的;
  2. 请求量:开发环境只有几个连接查询,在生产环境中可能同时有几百个并发查询,速度也会显著下降。

分析 Execution Plan 的结果会比简单的 Benchmark 更有自信(要格外注意 Predicate Information 中的 filter,filter 随时可能爆炸)。完全的压测也是有必要的,但是通常成本很高。

有人认为生产环境的机器配置更高,机器更多,可以解决查询慢的问题。但是大部分的慢查询都不能通过增加硬件来解决。21世纪以来 CPU 的性能没有很大提升,CPU 厂商都开始用多核技术来提高 CPU 的性能。如果仅仅是一个任务,那么开发用的笔记本和服务器效果是差不多的。生产环境就像高速公路,可以允许更多的车在上面跑,而它本身并不会让车跑的更快。相反的,生产环境的设施更加复杂,服务器之间的交互更多,加上防火墙等设施,在生产环境运行更有可能更慢。NoSQL 宣称解决一切问题,但是本质上它是用最终一致性换取了水平扩展写的能力。如果一个查询在关系型数据库很慢,那么即使迁移到 NoSQL 也会很慢。加快查询最靠谱的方法是利用好 B-Tree 索引。

另一个导致查询慢的原因是硬盘磁头 Seek 的次数太多了,虽然一次 Seek 只需要几毫秒,一次索引查询(B-Tree)最多也只会 Seek 4次,但是如果 join 表太多的话,就会 Seek 几百次。

所以再次强调,最靠谱的验证方式还是看执行计划。否则很容被开发环境小量数据的效果“欺骗”。

Join 操作

先来两个笑话吧(No hard feelings)。

笑话1:

SQL 走进了一家酒吧,看到有两张桌子(Table),他问:我能加入(Join)你们吗?

笑话2:

为什么前端程序员总是独自吃饭?

因为他们不懂 Join Tables.

回到正题,join 可以将分散在多个表中的数据重新组合起来。因为 join 需要聚合分散的数据,所以 join 操作对磁盘的 seek 非常敏感。正确的索引可以加快 join 操作,如何索引取决于使用哪种 join 算法。常用的一共有 3 种。但是这些算法的一个共同点是:一次只能 join 两个 Table。如果有多个 Table,那么先 join 起来前两个 Table,然后作为中间表,再去和后面的 join。所以下文在讨论的时候,都是讨论两张表之间的 join。

虽然 join 的顺序对最后的结果没有什么影响,但是对查询性能是有影响的。优化器需要从所有的可能中选一个最优的查询计划出来。在这种情况下,光优化查询的参数是不够的,因为优化器做选择本身就会花一些时间(n! 复杂度,n是查询条件)。当然,如果使用 bind parameters 就没有这个问题啦(参考我的第一篇笔记),所以参数越多,使用 bind parameters 就越有必要。

接下来介绍 3 种最常用的 join 算法:

  1. Nested Loops – 用循环来 join 两张表;
  2. Hash Join – 将一张表构建出来 Hash 表,然后对表2中的每一行记录,找到 hash 表中对应的行;
  3. Sort Merge – 将两张表排好序,然后用 Merge Sort 算法进行 join;

Nested Loops

这是实现起来最简单的一种方法:Join 表1 和表2,只要遍历表1中每一行,去表2 Select 就行了。

如果你使用 ORM 的话,可能遇到过一个经典的问题:N+1 select ——错误做法:从两张表查询 N 行记录,先在表1中查出来所有的行,然后用一个循环 Select 表2中的行。因为一共会执行 N + 1次 Select(第一次查询出所有记录,然后遍历N次),所以叫做 N+ 1 Select 问题。这样做可能在上线之后引起性能问题,因为执行了大量的 Select。

通过上面这个问题,可以比较好的理解 Nested Loops. 可以看出,join 的条件有没有索引,对 join 的性能影响很大。因为相当于执行的是 select。

通过 Nested loops 来进行 join 虽然和 ORM 这种 N+1 Select 的原理是相同的,但是用 SQL join 会更加高效。这是因为 join 是直接在数据库服务里面做的,而 N + 1 Select 要客户端和服务器来来回回对每一条 Select 进行请求和响应。

性能一般反应在两个方面:Response Time 和 Throughput,在网络上反应为延迟和带宽。对于数据库来说,延迟更加关键,带宽并不是很重要。

所以最好打开执行的 SQL 日志(一般的 ORM 都有这种选项),来看一下类似的操作是否是通过 SQL 来 join 的。

如果 join 条件有索引,并且数据集不大的话,Nested Loops 的性能是不错的。如果数据量大的话,优化器就会选择其他的 join 方式。

Hash Join

Hash Join 的原理是对一个表执行 Full table scan,将这个表 load 进 hash 表中,以 join 的条件作为这个 hash 表的 key;然后选出第二张表中,符合 where 查询条件的行,在 hash 表中找到对应的第一张表的数据。

将一张表加载到内存中,来避免了 join 的过程中多次 select。因为 join 的条件会被作为 hash 表的 key,本身就是 O(1) 的速度了,所以对 join 的条件建立索引,在 hash Join 中并不会起作用。

优化 Hash Join 速度的方法主要有两个:

  1. 加速遍历第二章表的速度,即对 where 条件建索引;
  2. 尽量减少 join 的内容的数量,这样降低 load 进内存的大小;水平角度,我们可以用 where 语句过滤出尽量少的行;垂直角度,我们只 select 需要的 column,而不是 select *

Sort Merge

Sort Merge 的原理很简单,将两张表排好序,然后用 sort merge 的算法将两张表 join 起来。

Sort Merge 所需要的索引和 Hash join 一样,尽可能加速查询,对 where 语句索引。join 的条件是没有必要索引的。

Sort Merge 对 join 的顺序完全不感冒,A join B 和 B join A 的结果是完全一样的,性能也是一样;

因为 Sort Merge 要将两边的表都排好序,所以代价很高,相比之下,Hash Join 只需要处理一边的表。所以 Hash Join 实际上更常用一些。但是有一种情况,就是如果输入的数据已经是排好序的,那么 Sort Merge 是非常快的。

 

集合里的元素怎么“不见了”?

昨天花时间在 debug 一个非常诡异的问题,Java 代码里面的一个 HashSet 集合里面命令包含我这个元素,equals hashCode 都一样,甚至对象的 id 都是一样的,但是 contains 方法返回的结果总是 false 的!最后花了很多时间,百思不得其解,一度怀疑我生活在 Matrix 里面。最后发现问题的一刻也恍然大悟,发现这是一个我早就知道的问题。这必定成为我职业生涯的一个污点,所以我打算记录一下这个问题。

先卖个关子吧,我来描述一下问题的背景,看你能否想到答案。

问题是这样的,我们用 JGraphT 来解决一个图的问题。这个图是我们从应用的调用关系链中生成的,生成之后会导出到 json,放到一个地方。然后所有的计算节点都可以通过这个 json 来 load 图,就不用每个节点都去清洗一遍了。一个节点清洗过后,所有的节点都从这里加载。问题主要出现图的导出和导入,图中每个节点都有一个 id,一开始我用应用的名字作为 id,导出到 json,但是导入的时候发现 Importer 会重新生成 ID,图的关系是对的,但是节点的 ID 从字符串变成了重新生成的 id 了,那么应用名字的信息就丢失了。我又给节点加上 name 属性,期望这个属性 import 之后还是好的。结果发现 import 只是 import 图的关系,并没有 import 进来其他属性。(这个库看起来很 nice 啊,不知道为啥文档这么差,import 的细节都没有文档)于是我参考 Test 里面的做法,用一个 Map 存下来节点的其他属性。然后在 import 完成之后,将这些属性 set 进去。

OK,总结一下,简单来说就是,我先从 json 导入进图,导入的时候也存下来每个节点的属性(其实就是name),导入之后遍历图的节点,将每个属性设置进去。

问题就出现了,我用图来找最短路径的时候,报错:节点不存在!

定位到库里面,判断节点不存在的 contains 函数是这么写的:

我 debug 了这个 Set 和 v 的关系,发现 Set 中的一个元素,跟 v 是一模一样的!对象 id 都是一样的。

equals 返回值是一样的:

hashCode 返回值是一样的:

但是这个 contains 函数就是返回 false.

为了让这个问题更明显一些,我将这个问题简化成以下可以直接运行的一段 Java 代码:

运行结果如下:

明明 equalshashCode 都一样,为什么 contains 就是 false 呢?


答案就在查找 Hash 表的方式。我之前写过一篇文章,介绍如果发生 hash 碰撞,那么 hash 表一般会通过某种方式存放 hash 相同的元素。这就要求,在 hash 表中查找元素的时候,必须满足以下两个条件,才算是找到了元素:

  1. 按照 hash 值能找到这个元素所在的 hash 位置,但是这个位置存放着很多 hash 值相同的元素,所以还要满足2;
  2. 必须满足相等(equals)

HashSet 其实就是没有 value 的 HashMap,本质上也是个 hash 表,所以 contains 要返回 true,也必须满足上面两个条件。元素在存进去的时候,name 是空的,按照 namenull 得到了一个 hash 值,放到了 HashMap 的一个地方,记做位置A。然后我后来修改 name 的值,再 hash 的时候,就会得到另一个 hash 值,记做位置B. 然后 contains 去位置 B 一看,这个位置是个null,就认为这个元素不在集合中了。

为什么 hashCode 和 equals 返回都是相等的呢?因为我们先按照 name = null 保存了进去,保存的时候 hash 值已经确定了,后来修改了 name,hash 值已经不会修改(不会在 HashSet 里面移动的)。虽然对象即使是同一个对象,但是 hash 值已经和放进去的时候变了。拿现在的对象(Set里面的那个对象,和现在的要确定是否被 contains 的对象,都是“现在的对象”,name 已经被修改了的)来对比 hash 值肯定是相等的,但是已经和放进去的时候的那个 hash 值不同了。去看 HashSet 中,现在的这个 hash 值的位置,肯定是个 null,所以判断为元素不存在。

简单总结一下,就是放入 Hash 中的元素,一定要是不可修改的(这个和 Python 为什么list不能作为字典的key?的原理是一样的)。如果修改了,那这个元素就从集合中找不回来了。

最后,从这个故事中我们能学到什么呢?

感觉学不到什么,现在回想起来就跟自己的智商收到了降维打击一样。

哦对了,如果你看懂了这个问题,那么就会理解,之所以找不到这个元素是因为这个元素放进去的时候的 hashCode 和现在的这个元素的 hashCode 已经不一样了。我不禁回忆起另外一个问题:

有三个人去住旅馆,住三间房,每一间房$10元,于是他们一共付给老板$30,第二天,老板觉得三间房只需要$25元就够了,于是叫小弟退回$5给三位客人。

谁知小弟贪心,只退回每人$1,自己偷偷拿了$2,这样一来便等于那三位客人每人各花了九元,于是三个人一共花了$27,再加上小弟独吞了不$2,总共是$29。可是当初他们三个人一共付出$30那么还有$1呢?

 

IRedis 1.0 发布

这算是 IRedis 开发系列的第4篇笔记吧。这篇文章来聊一下 IRedis 最近发布 1.0 带来的新 Feature,一些开发过程中的思考,以及最后贴一下发布之后的一些“成就” (炫耀)。上次提到的介绍一些开发中使用的 tricks,就让我再拖到下一篇博客吧!

PS: 如果您还不知道 IRedis 的话,可以理解成是 redis-cli 的一个替代品。类似于 IPython 相比于 python 官方的 REPL,专注于用户体验,支持语法高亮,命令提示,自动补全等。全部的 Feature 可以参考项目 Readme,以及官网。

New Features

Changelog 是从 0.8 开始记录的,这里说一下几个两点功能:

支持了 Peek 命令,用 Redis 的时候不需要先用 type 看一下 key 的类型,然后再去选择对应的命令来操作了;

支持了配置文件 ~/.iredisrc.

支持了 Redis 所有的命令,支持了时间戳补全(没有人想在命令行输入时间戳!),支持了 int 类型补全。(我发现实现补全器很有意思呢!)

更多的功能演示 gif 可以见这个页面

Coming Features

  1. 支持用 URL 来传入 Redis 连接参数,比如 iredis --url redis://username:[email protected]:6379/5 lyqsmy 正在实现这个 Feature
  2. 支持通过 alias_dsn 连接 redis,比如把上面这个 url 保存在 ~/.iredisrc 里面,然后在命令行通过 iredis --dsn main 去连接。
  3. 将 IRedis 打包成单个文件,通过 curl 就可以下载到机器上执行。目前采用的方案是 PyOxidizer,遇到不少问题,mac-chaffee 正在做这项工作

开发思考

最想分享的是单元测试了,开发一个又一个Feature的时候,测试挂了 N 多次,每次挂了我都庆幸一次幸亏写了测试。分享一下 piglei 的文章,以及我在评论里留下的一些想法:

我也想分享一些有关测试的想法。我很喜欢写测试,最近在写的 https://github.com/laixinta… 搞了一年多,测试挂了无数次。测试这个事情很反直觉的一点是,你觉得测试会增加开发成本,而事实相反。我知道我的测试覆盖了哪些地方,所以,我可以放心大胆的重构,主要测试pass了,我就敢发布新版本。随着代码越写越多,开发效率一点也没有降低,有什么新功能直接写就行了,之间的函数抽象不够直接重构,然后修复测试。这个项目到今天还是能保持着只要合并了master就可以作为新的feature立即发布,甚至我的发布流程也是交给CI的,我只要打tag push就好了。

另外有几点感受:

  1. 写稳定的测试是很重要的,有时候我们会因为assert 的list顺序不一样而导致测试随机挂掉,一定要找到原因,保持测试的可信度;
  2. 稳定的CI服务很重要,我从 circleCI 换到travis,现在用Github Action,CircleCI因为自身问题挂过3次,travis从没挂过,Github挂过1次(目前),如果CI变成“这次挂了,但可能不是我的问题,我要re-run试试”,体验是很糟糕的;
    CI的速度很重要。现在有200+测试,刚开始需要5min在CI跑完,后来我对venv加了cache,测试3个Python版本只要1min了;
  3. 覆盖率不重要。盲目追求100%是没有意义的。比如有些代码你写了 `re.match(“xxx”, str)` 你知道这个正则需要match很多种情况,但是其实只要你写1种,对覆盖率检测来说,它就以为你覆盖了,但其实需要更多的case来发现问题。还有一种情况,在函数的入口做参数检查,比如两个 kwargs 不能同时出现,这种代码很难出错,其实没必要测试(当然写一个也就几行)。所以,我现在觉得100%的测试覆盖并不能说明一个库是质量好的,覆盖率70%也并不能说明这个库测试不够完全;
  4. 应该一开始就写测试。以前我的想法是先写好功能,写的完善了,到1.0了,再开始写测试。这种想法是不对的,应该在POC的时候就写POC的测试,这样可以保证开发质量,和开发效率。有一个额外的好处是,这时候的测试可以作为样例代码,供用户或同事参考。
  5. 我觉得理想的开发团队(虽然我没遇到过)应该用测试来保护自己的代码,如果别人修改了我的部分,并且全通过了我的测试,但是引入了BUG,那我会认为是我的责任。

—— on 《游戏《蔚蓝山》教我的编程道理

另外,我发现 Python 里面的 Generator 很容易被忽略。

第一种情况是很容易忽略很多内置的函数返回的是 Generator,只能迭代一次。比如我在写 Completer 的时候写过的下面这个代码:

导致这个 Completer 只在第一次使用的时候有用,这个现象很奇怪,我花了几十分钟才意识到这 reversed() 返回的不是 list,只是 generator,只能用一次。

另外情况就是一个函数很长,中间出现了一个 yield,就从一个 function 变成 generator maker 了(我觉得很多地方搞混了“生成器”和“生成器 maker”,def foo(): yield "hello" 这其实不是 generator,只是制造出 generator 的一个东西,每次调用都会出现一个新的 generator,所以我把它叫做 generator maker)。然后现象也很奇怪,就是这个函数根本没跑。找了好久才找到原来是没用 next() 激活。

说到底,说他“坑”的原因就是这种情况完全不会报错,不会有任何错误,完全隐藏在了程序的逻辑中。很难排查。


以下是发布之后受到的关注:

1.0 之后我在 Hackernews 贴了一下,但是一直都没什么人,感觉在 HN 发帖子很难受到关注,结果第二天起床一看,竟然被顶上了首页:

上Top10了, 没有截图到,放个Twitter吧

还没有写完的时候,RedisWatch就推荐过一次,80期

被 RedisWatch 第二次推荐 (第84期)

还有两个哥们跟我说他们在微博看到有人转发我的项目了,开心

PyCoders推荐了

本来想让DB Weekly推荐一下的,但我还没联系他们,下一期就出来了,封面竟然是IRedis!

IRedis的Itamar的评价

prompt-toolkit 作者的评价