Java8 Stream API 介绍

Java 从版本8开始支持“Stream API”,即函数式编程,可以用简单的代码表达出比较复杂的遍历操作。本文介绍这些 Stream API 的基本概念,用法,以及一些参考资料。我之前写 Python 比较多,所以一些地方可能用 Python 的视角来解释。

简单用法

这些函数和 Python 的 filter map sort 很像了,所以很容看懂。就是先过滤出以 "b" 开头的字符串,然后用 map 转换成大写的方式,排序之后,输出。

Stream API 中有一个概念,将这些 API 分成了两种:

  1. 中间结果(intermediate):像 filter map sorted 的结果都是中间结果,可以继续使用 Stream API 连续调用;
  2. 最终结果(terminal):类似 forEach ,这种 API 将会终止 Stream 的调用,它们的结果要么是 void ,要么是一个非 Stream 结果。

连续调用 Stream 的方式叫做  operation pipeline。所有的 Stream API 可以参考这个 javadoc

大多数的 Stream 操作都接收一个 lambda 表达式作为参数,Lambda 表达式描述了 Stream 操作的一个具体行为,通常都是 stateless 和 non-interfering 的。

Non-interfering 意味着它不会修改原始的数据,比如上面的例子,没有操作去动 myList,迭代结束之后,myList 还是保持着原来的样子。

Stateless 意味着操作都是确定的,没有依赖外面的变量(导致可能在执行期间改变)。

不同类型的 Stream 操作

Stream 可以从不同的数据类型创建,尤其是集合(Collections)。

List 和 Set 支持 stream() 方法和 parallelStream() 方法,parallenStream() 可以在多线程中执行。

在 List 上调用 stream()可以创建一个 Stream 对象。

但是我们不必专门为了创建 Stream 对象而创建一个集合:

Stream.of() 可以从一些对象引用中自动创建一个 Stream。

除了从对象创建 Stream,Java8 还提供了方法从基本类型创建 Stream,比如int long double,这些方法分别是 IntStream LongStream DoubleStream.

IntStream 可以用来替代 for 循环:

基本类型的 Stream 和普通的 Stream 对象基本一样,几点区别如下:

  1. 基本类型使用特殊的 lambda 表达式,比如 IntFunction 之于 Function , IntPredicate 之于 Predicate;
  2. 基本类型支持一些特殊的“最终结果API”,比如 sum() average()

有时候,我们想把普通的 Stream 转换成原始类型的 Stream,比如我们想用 max() ,这时可以使用转换的方法 mapToInt() mapToLong() mapToDouble():

原始类型可以通过 mapToObject 方法,将原始类型的 Stream 转换成普通的 Stream 对象。

下面这个例子结合了普通的 Stream 和原始类型的 Stream:

执行顺序

前面介绍了 Stream 的基本概念,下面开始深入原理。

产生中间结果的 Stream 一个比较重要的特性是,它是惰性的。下面这个例子,我们只有中间结果,没有最终结果的 Stream,最终 println 不会被执行。

因为 Stream 操作是惰性的,只有用到的时候才会真正执行。

如果我们在后面加上一个终止类型的 Stream 操作,println 就会执行了。

这段代码的输出如下:

注意从输出的顺序也可以看到“惰性执行”的特征:并不是所有的 filter 都打印出来,再打印出来 forEeach。而是一个元素执行到底,再去执行下一个元素。

这样可以减少执行的次数。参考下面这个例子:

anyMatch 会在找到第一个符合条件的元素就返回。这样我们并不需要对有的元素执行 map ,在第一个 anyMatch 返回 true 之后,执行就结束了。所以前面的中间状态 Stream 操作,会执行尽可能少的次数。

执行的顺序很重要(Stream 的优化)

下面这个例子,我们用了两个生成中间结果的 Stream 操作 map filter,和一个最终结果的操作 forEach 。

map filter 各执行了5次,forEach 执行了1次。

如果我们在这里稍微改变一下顺序,将 filter 提前执行,可以将 map 的执行次数减少到1次。(有点像 SQL 优化)

现在 map 只执行一次了,在操作很大的集合的时候非常有用。

下面我们引入一下 sorted 这个操作:

排序是一个特殊的中间操作,是一个 stateful 的操作。因为需要原地排序。

输出如下:

排序操作会在整个集合上执行。所以和之前的“垂直”执行不同,排序操作是水平执行的。注意排序影响的只是后面的 Stream 操作,对于原来的集合,顺序依然是不变的。参考这段代码:

输出如下(注意原来的 foo 并没有变化):

Sort 也有惰性执行的特性,如果我们改变一下上面那个例子的执行顺序:

可以发现 sorted 不会执行,因为 filter 只产生了一个元素。

Stream 的重用

Java8 的 Stream 是不支持重用的。一旦调用了终止类型的 Stream 操作,Stream 会被 close。

在同一个 Stream 上,先调用 noneMatch 再调用 anyMatch 会看到以下异常:

所以,我们必须为每一个终止类型的 Stream 操作创建一个新的 Stream。可以用 Stream Supplier 来实现。

每次调用 get() 都会得到一个新的 Stream。

高级操作

Stream 支持的操作很多(不像Python的函数式编程只支持4个)。我们已经见过了最常用的 filter 和 map。其他的操作读者可以自行阅读 Stream 文档。这里,我们再试一下几个复杂的操作:collect flatMap reduce.

下面的例子都会使用一个 Person 的 List 来演示。

Collect

Collect 是很有用的一个终止类型的 Stream 操作,可以将 Stream 转换成集合结果,比如 List Set Map 。Collect 接收一个 Collector 作为参数,Collector 需要支持4种操作:

  1. supplier
  2. accumulator
  3. combiner
  4. finisher

听起来实现很复杂,但是好处是 Java8 已经内置了常用的 Collector,所以大多数情况下我们不需要自己实现。

下面看一个常用的操作:

可以看到这个 Stream 操作最后构建了一个 List,如果需要 Set 的话只需要将 toList() 换成 toSet()

接下来这个例子,将对象按照属性存放到 Map 中。

Collectors 非常实用,还可以对 Stream 进行聚合,比如计算所有 Person 的平均年龄:

如果需要更全面的统计数据,可以试一下 summarizing Collector,这个内置的 Collector 提供了 count, sum, min, max 等有用的数据。

下面这个例子,将所有的对象 join 成一个 String:

Join Collector 的参数是一个分隔符,一个可选的前缀和后缀。

将 Stream 元素转换成 map 的时候,需要特别注意:key 必须是唯一的,否则会抛出 IllegalStateException 。但是我们可以传入一个 merge function,来指定重复的元素映射的方式:

最后,来尝试一下实现自己的 Collector。前面已经提到过,实现一个 Collector,我们需要提供4个东西:supplier,accumulator,combiner,finisher.

下面这个 Collector 将所有的 Person 对象转换成一个字符串,名字全部大写,中间用 | 分割。

Java 的 String 是不可修改的,所以这里需要一个 helper class StringJoiner,来构建最终的 String。

  1. 首先 supplier 构建了一个 StringJoiner,以 | 作为分隔符;
  2. 然后 accumulator 将每个 Person 的 name 转换成大写;
  3. combiner 将2个 StringJoiners 合并成1个;
  4. 最后 finisher 从 StringJoiner 构建最终的 String。

FlatMap

前面我们演示了如何用 map 将一种类型的对象转换成另一种类型。但是 map 也有一些限制:一个对象只能转换成一个对象,如果需要将一个对象转换成多个就不行了。所以还有一个 flatMap 。

FlatMap 可以将 Stream 中的每一个对象转换成0个,1个或多个。无论产生多少对象,最终都会放到同一个 Stream 中,供后面的操作消费。

下面演示 flatMap 的功能,我们需要一个有继承关系的类型:

下面,我们使用 Stream 来初始化多个几个对象:

现在我们生成了一个 List,包含3个 foo,每个 foo 中包含3个 bar.

FlatMap 接收一个方法,返回一个 Stream,可以包含任意个 Objects. 所以我们可以用这个方法得到 foo 中的每一个 bar:

上面这个代码将 foo 的 Stream 转换成了包含 9 个 bar 的 Stream。

上面所有的代码也可以简化到一个 Stream 操作中:

flatMap 中也可以用 Optional 对象,Optional 是 Java8 引入的,可以检查 null 的一种机制。结合 Optional 和 flatMap 我们可以相对优雅地处理 null ,考虑下面这种数据结构:

为了正确地得到 Inner 中的 foo String,我们要这么写:

flatMap 的话,我们可以这么写:

每一个 flatMap 都用 Optional 封装,如果不是空,就返回里面的对象,如果是空的话就返回一个 null .

Reduce

Reduce 操作可以将所有的元素编程一个结果。Java8 支持3种不同的 reduce 方法。

第一种可以将 Stream 中的元素聚合成一个。比如下面的代码,可以找到 Stream 中年龄最大的 Person.

reduce 方法接收一个二元函数(一个只有两个参数的函数)作为参数,返回一个对象。(所以叫做 reduce)

第二种 reduce 接收一个初始对象,和一个二元函数。通常可以用于聚合操作(比如累加)。

第三种 reduce 方法接收3个参数:一个初始化对象,一个二元函数,和一个 combiner 函数。

初始化值并不一定是 Stream 中的对象,所以我们可以直接用一个整数。

结果依然是 76,那么原理是什么呢?我们可以打印出来执行过程:

可以看到 accumulator 做了所有的工作,将所有的年龄和初始化的 int 值 0 相加。但是 combiner 没有执行?

我们将 Stream 换成 parallelStream 再来看一下:

这次 combiner 执行了。并发执行的 Stream 有不同的行为。Accumulator 是并发执行的,所以需要一个 combiner 将所有的并发得到的结果再聚合起来。

下面来看一下 Parallel Stream。

Parallel Stream

因为 Stream 中每一个元素都是单独执行的,可想而知,如果并行计算每一个元素的话,可以提升性能。Parallel Stream 就是适用这种场景的。Parallel Stream 使用公共的 ForkJoinPool 来并行计算。底层的真正的线程数据取决于 CPU 的核数,默认是3.

这个值可以通过 JVM 参数修改:

Collections 可以通过 parallelStream() 来创建一个并行执行的 Stream,可以在普通的 Stream 上执行 parallel() 来转换成并行执行的 Stream。

下面这个例子,将并行执行的每一步的线程执行者打印出来:

输出如下,展示了每一步都是由哪一个线程来执行的:

从上面的结果页可以看出,所有的 ForkJoinPool 中的线程都参与了计算。

如果在上面的例子中加入一个 sort 操作,结果就有些不同了:

结果如下:

看起来 sort 好像是顺序执行的。实际上,sort 使用的是 Java8 的 Arrays.parallelSort() 方法,文档里提到,这里的排序是否真正的并行执行取决于数组的长度,如果长的话就会用并行排序,否则就用单线程排序:

If the length of the specified array is less than the minimum granularity, then it is sorted using the appropriate Arrays.sort method.

 

回到之前的 reduce 方法,我们知道 combiner 只会在并行的时候执行,现在来看一下这个方法到底是做什么的:

可以看到 accumulator 和 combiner 都使用了多线程来运行:

综上,在数据量很大的时候,并行执行的 Stream 可以带来很大的性能提升。但是注意像 reduce 和 collect 这样的操作,需要特殊的 combiner。(因为前一操作产生的类型不同,需要做聚合,所以无法和迁移操作的函数一样,需要另外提供)。

另外要注意的是,Parallel Stream 底层使用的通用的 ForkJoinPool ,所以需要注意不要在并行的 Stream 中出现很慢或阻塞的操作,这样会影响其他并行任务。

 

以上就是基本的 Stream API 介绍了,强烈建议阅读 Java8 的官方文档。

参考资料:

  1. package summary
  2. Document
  3. Toturial
  4. Java 8 Stream Tutorial
  5. Collection Pipeline by Martin Fowler
  6. Stream.js Javascript 版的 Java Stream API
 

IRedis 开发记2:CircleCI workflow 自动发布到 pypi

上周末设置了一下在 CircleCI 的流程,之前也讲过如何设置一个 Django 的 CircleCI,那只是一个简单的 CI build。本文讲一下如何设置一个更加复杂和自动化的流程。以及 IRedis 项目最近一些开发进展(项目主页)。

完整的 Workflow 🎉

CircleCI 整体的使用体验不错,相比于之前只用了 jobs,这一次引入了 workflow 的概念。workflow 就是先定义好 jobs,然后在 Workflow 中将这些 jobs 组合起来,形成一个完整的开发部署流程。

现在,只要我在 master 分支上打一个 tag,就会触发整个构建流程:

首先会进行单元测试、black 强制代码风格检查,flake8 检查,全部通过后就会构建一个 pip package,然后测试这个 package 是否可用,确认可用之后上传到 pypi。上传这一步只会在有 tag 的时候执行,如果没有 tag 就说明不是一个新版本,不会上传。

实现方法可以参考这个 PR。总体上是比较简单的,就是用 yaml 文件编排一个 workflow。

这里有一个小坑,是完成“只在有 tag 的时候上传”这个语义实现上。CircleCI 不支持“仅在 Master 分支上,有 tag 的时候执行”这个语义的。即不支持 AND 关系,只支持 OR 关系。

如果你使用下面这种声明方式:

那么实际的意思是:

  1. master 分支所有的 commit 都执行;
  2. tag 符合条件的都执行;

即满足任何一个条件都会执行,而不是两者同时满足才会执行。

这个坑踩了很久,才发现一个 work around 的方法:

这样首先我们忽略所有的 branch,这样所有的分支都不会执行,然后只有在有新 tag 的时候执行。就可以完成上传的需求了。

上传到 Pypi 很简单,首先 pypi.org 提供了针对每一个包设置一个上传 Token 的功能,非常好用,权限最小化。然后 CircleCI 设置一个环境变量,在 CI 中读取这个环境变量即可。配合上传包的命令行工具 twine 可以这么用:

其中注意 Token 的申请,和设置,需要分别在 Pypi.org 和 CircleCI 的 web 界面上操作好。

Workflow 中可以添加定时构建任务,在需要 nightly build 的项目中非常实用;也可以添加需要手动确认的任务,比如将自动发布集成到流程中,但是发布之前需要手动确认一下。More on this. 但是要知道,每一个手动确认的东西,在增加了安全性的同时,也拉长了整个流程,有人操作的地方就会卡住,会成为最耗时的东西,某一天也许会成为一种形式主义,所以我个人非常不喜欢需要人工确认的东西。

除了发布到 pypi,因为这个项目是一个 Redis 的命令行工具,所以也准备发布到其他的软件源,比如 apt,yum,brew 等。

再来说下这段时间其他一些更新。

更友好的补全

key 的补全,我自己实现了一个超屌的 Completer,在做自动补全的时候,最近使用过的 key 会出现在最前面(PR)。除了 KEYS 命令之外,所有包含 key 的 Redis 命令都会更新 Completer。效果如下(注意所有包含 key 的命令,都将输入过的 key 放到了 completers 列表中):

支持命令提示

也许在上张图你发现了,在 最底下一行含有命令的提示,包括这个命令适用于 Redis 哪种数据类型,命令的语法是什么,什么 redis-server 版本出现的,命令的复杂度是什么。redis-cli 的 readline 风格提示,在输入的过程中如果匹配不上命令,hint 会消失,我觉得体验不太好。另外输入的阴影我用来显示历史命令了,用  键可以快速输入。

放一张开发的时候写的脚本,渲染出所有的命令(laike9m说我的配色辣眼睛):

Transaction 支持

这是某天我想出来的一个 idea,觉得不错,就是在 transaction 状态中的时候,用右侧的 prompt 来提示用户状态。这个想法我记了 issue,Github 上的哥们帮我实现了(Thanks guoweikuang)。

效果如下:

其他还有一个项目内部中的重构,比如一套更加合理的命令前 hook,命令后 hook 方式,命令结果渲染框架等。打包方式也替换成了 poetry,写 pyproject.toml 比写 setup.py 爽多了。

发点牢骚,很多人对这种工具开发不屑一顾,张口闭口高并发,觉得架构设计啊才能显示出自己的技术,但是代码写的稀烂,一个很简单的系统虽然几十万并发量,却用了上千台机器,算下来一分钟请求才几百而已,非常可笑。我觉得一个靠谱的程序员首先应该精通自己的工具,工具不好工作就不能高效,自己的工具遇到问题解决不好,工作上遇到问题也稀松。自己的问题没有现成的工具解决,就没办法了,工作上除了搭积木水平也一般。

牢骚发完,顺便说下,这个项目正在开发中,如果你对一个 Redis 命令行有什么期待,有什么 Feature Request,或者想法,欢迎 issues,我来实现。也欢迎参与到开发中。目前有很多命令还没有实现,最近我在写 string 部分的命令。打算在所有的命令都实现了语法解析和结果渲染之后发布1.0版本。

 

认识Hibernate

最近工作的项目中,用的 ORM 技术是 Hibernate,学习了一下它的用法,正好 PyCon 上我有一个演讲主题是介绍 Django 的 ORM,可以拿来比较一下。这篇文章介绍了 Hibernate 的定位,基本的概念,以及用代码演示了如何使用 Hibernate。本文的内容参考了 jboss 上的一篇教程,所有的代码直接下载:hibernate-tutorials.zip

Hibernate 是什么?

Java 是一个面向对象的编程语言,数据库提供的数据结构只有 Table。所以我们在读写数据库的数据的时候不可避免的要进行结构的转换,保存数据的时候,将 Object 变成 Table 可以保存的形式,读回数据的时候要转换回来。

这样每次在读写数据的时候做转换,是非常重复的,开发成本很高。Hibernate 就是一个 对象/关系映射 的转换解决方案。在 Java 程序中,我们只写类,数据在保存的时候,Hibernate 负责将类的属性转换成 Table 的 Column。更确切的说,如果没有 Hibernate,我们就需要通过写 SQL 和用 JDBC 来跟数据库交互。

和其他 ORM 不同,Hibernate 没有完全屏蔽 SQL。并承诺你的关系型数据库的知识,在 Hibernate 下依然有价值(来源)。

一、使用 Hibernate 原生的 API 读取和保存数据

ORM 需要做的最终要的一件事情是负责 Class 和 Table 的数据结构的转换,在 Hibernate 中,定义这种转换有两种方式:通过配置文件,或者通过注解。

使用 Mapping 配置文件来映射Java class和Table的关系

Bundle/resources 下面的 hibernate.cfg.xml 是 Hibernate 的配置文件。有数据库地址,连接参数等配置。其中 dialect 定义了使用哪种SQL方言,在使用特定数据库的时候可以使用。

auto 可以指定启动的时候自动创建表结构:

这个值可选的参数如下:

  1. create:每次加载hibernate时都会删除上一次的生成的表,然后根据你的model类再重新来生成新表,哪怕两次没有任何改变也要这样执行,这就是导致数据库表数据丢失的一个重要原因。
  2. create-drop :每次加载hibernate时根据model类生成表,但是sessionFactory一关闭,表就自动删除。
  3. update:最常用的属性,第一次加载hibernate时根据model类会自动建立起表的结构(前提是先建立好数据库),以后加载hibernate时根据 model类自动更新表结构,即使表结构改变了但表中的行仍然存在不会删除以前的行。要注意的是当部署到服务器后,表结构是不会被马上建立起来的,是要等 应用第一次运行起来后才会。
  4. validate :每次加载hibernate时,验证创建数据库表结构,只会和数据库中的表进行比较,不会创建新表,但是会插入新值。

<mapping /> 可以告诉 Hibernate,去哪里找描述 Class 和 Table 对应的 mapping 文件。

对应路径的 mapping 文件,描述了 Java 的 Class 如何和数据库的 Table 对应:

Hibernate 会使用 java.lang.ClassLoader 去加载这些类。

有关 Entity 有两点需要注意:

  1. 这个类用标准的 JavaBean 命令方式设置 getter 和 setter 方法,private 属性也有。这是推荐做法但不是必须做法;
  2. 无参数构造函数是必须有的,Hibernate 需要用Java的反射通过这个构造函数构造对象。

Mapping 文件的详细解释

父节点的属性中,name 结合 package 定义个 FQN,对应 java 的 class;table 定义了数据库的表名字;

子节点中:

  • 需要定义 <id/> Element 来告诉 Hibernate 如何找到表中的唯一的一个 row;推荐使用 Primary key
  • <property/> 定义了属性和表字段的 mapping,column 定义了表 Column 的名字,如果不写的话 Hibernate 默认会使用 property 的 name;
  • type 是数据类型。这个数据类型既不是 SQL 的类型,也不是 Java 的类型,而是 Hibernate 的类型,负责在 Java 和 SQL 之间做转换。如果这个 type 没写的话,Hibernate 会尝试通过 Java 的反射,按照 Java 的类型找到对应的 mapping 类型;

测试代码

示例代码如下(不要复制粘贴,如果需要运行请直接复制本文开头提供的附件):

下面来分析代码。

在 setUp 中,首先构建了一个 org.hibernate.boot.registry.StandardServiceRegistry ,用来处理 hibernate.cfg.xml 等配置信息(sessionFactory 会使用 serviceRegistry 中的配置信息)。

在 setUp 中,首先构建了一个 org.hibernate.boot.registry.StandardServiceRegistry ,用来处理 hibernate.cfg.xml 等配置信息(sessionFactory 会使用 serviceRegistry 中的配置信息)。

我们使用 org.hibernate.boot.MetadataSource 先创建了 org.hibernate.boot.MetadataSources ,这个类表示了 domain Model。然后基于此创建了 SessionFactory。

最后一步就是创建 SessionFactory 了,这是一个全局单例的类,线程安全。

SessionFactory 是 Session 的工厂类,每次使用一个 Session 来完成一块任务:

以上代码中,testBasicUseage() 先创建了一个 Event 对象,然后交给 Hibernate 处理,Hibernate 的 save() 方法将其插入数据库中。

获取对象的代码如下:

我们通过写 Hibernate Query Language 向 Hibernate 表达查询,Hibernate 将其转换成 SQL 执行查询,然后将结果转换成我们需要的 class。

使用注解描述 Java class 和 Table 的映射关系

使用注解API,在配置文件中我们的 mapping 字段需要设置为:

其他的配置项和前面一样。然后,我们在定义 class 和 table 的映射的时候,不再使用 xml 文件了,而是直接在 class 上面注释:

@Entity 的注解和 xml 中的 <class /> 作用一样。这里显示地指定了表的名字,如果不指定的话,就会默认使用类名 EVENT

字段的定义如下:

@javax.persistence.Id 定义了实体的 ID;

@javax.persistence.GeneratedValue@org.hibernate.annotations.GenericGenerator 指示 Hibernate 应该用 increment 生成器自动生成这个字段。

同 xml 配置方式一样,date 也需要特殊处理一下。

实体的属性默认将会持久化,所以这个类中我们没有写 title 的 annotation,但是 title 也会被持久化。

示例代码:这部分的例子和上面一样。

二、使用 Java Persistence API(JPA)

上面我们使用的配置文件是 hibernate.cfg.xml,而 JPA 定义了自己的配置方式,叫做 persistence.xml 。启动方式是 JPA 定义的规范,Hibernate 作为持久化的提供者,需要读取并应用 META-INF/persistence.xml 里面的设置。

persistence.xml 文件范例如下:

对于每一个 persistence-unit 需要提供一个 unique name。应用在获得 javax.persistence.EntityManagerFactory 引用的时候,使用这个 unique name 来获得配置的引用。

之前的配置文件中的配置项,先在要使用 javax.persistence 前缀来区分开。对于 Herbenate 特殊肚饿配置项要用 hibernate. 前缀。

除此之外,<class /> 的内容和我们在前面看到的 Hibernate 配置文件一样。

示例代码

前面的例子中使用的是 Hibernate 的 native API,这里我们使用 JPA 的 API 。

获取 SessionFactory:

注意这里使用的 persistence unit name 是 org.hibernate.tutorial.jpa ,和上面提到的配置问题件对应。

使用 javax.persistence.EntityManager 保存实体。Hibernate 中 save 的步骤,在 JPA 中叫做 persist.

三、Envers 功能

Hibernate 有一个功能,叫做 Envers,就是可以将数据的历史版本都保存下来。数据及时更改了,也可以找到曾经的状态。

这个功能也不是无成本的,需要一些保存历史版本,保存和读取的时候,也需要额外的计算,所以我们只在需要的时候这么做。

我们通过@org.hibernate.envers.Audited 这个注解告诉Hibernate需要保留这个类的历史版本,然后,我们可以 org.hibernate.envers.AuditReader 来读取数据的历史版本。

示例代码

 

参考资料:

 

欧格玛教会与言论自由

我喜欢欧格玛教会,欧格玛教会认为知识是至高无上的,尤其是最原始的知识,最纯粹的理念。理念没有重量,但是可以移山。

欧格玛教会信奉没有任何知识是应该被销毁的,知识本身是没有问题的,可怕的是使用它的人。即使是最疯狂、最错误的想法,藏匿起来也无济于事,应该交给大众来判断。所以欧格玛教会神殿内经常会藏有被其他教会认为是禁忌的知识,比如死灵术。

欧格玛信仰如下:

Knowledge, particularly the raw knowledge of ideas, is supreme. An idea has no weight, but it can move mountains. The greatest gift of humankind, an idea outweighs anything made by mortal hands. Knowledge is power and must be used with care, but hiding it away from others is never a good thing. Stifle no new ideas, no matter how false and crazed they seem; rather, let them be heard and considered. Never slay a singer, nor stand by as others do so. Spread knowledge wherever it is prudent to do so. Curb and deny falsehoods, rumor, and deceitful tales whenever you encounter them. Write or copy lore of great value at least once a year and give it away. Sponsor and teach bards, scribes, and record keepers. Spread truth and knowledge so that all folk know more. Never deliver a message falsely or incompletely. Teach reading and writing to those who ask (if your time permits), and charge no fee for the teaching.

我在我的博客上写着:我的梦想是让网络变得更加开放、自由和快速。

对于网络,我的想法也是所有的内容不都应该被删除、藏匿。应该交由大众去判断。这是我理解的言论自由。但是最近发生了两件事,让我开始怀疑自己的想法。

第一个是 Cloudflare 停止了对 8chan 的服务

CloudFlare 是我非常崇拜的一家公司,我一度认为互联网服务就应该是 CloudFlare 提供的那样。但是有证据证明,一起枪击事件的犯罪嫌疑人从 8chan 上受到了鼓动,从而导致了这起枪击事件。8chan 创建的本意是 4chan 依然会删除部分内容,所以 8chan 想创造一个不会删除内容的 4chan,最终却成为了仇恨的温床。

第二个是Twitter关闭了一批来自大陆的账号

这么做的理由是 Twitter 认为,这部分账号进行了受组织协调的行动。

那么究竟,我们需要怎么样的言论自由呢?

 

IRedis 开发记录:Redis 命令语法的处理

我最近在写一个命令行应用程序,一个支持自动补全、命令校验、语法高亮的 redis-cli 。项目的地址:

https://github.com/laixintao/iredis

写这个插件是想达到 mycli/pgcli 的那种效果,让 redis-cli 用起来非常顺手,本质上,这又是一个满足我自己个人需求的项目。但是我觉得应该很多人会喜欢这个。

开发的过程中遇到了很多有意思的问题,一直想分享一下,但是最近太忙了,白天工作比较忙。晚上我基本都花在写这个项目的代码上,没有时间系统的写一下。今天这篇文章批评了我这里面写过的一段正则,所以今天借这个机会,就写一下吧。

这个正则在项目的代码库里面:代码地址

为什么用这么大一个正则表达式呢?

完成语法校验、高亮,基于语法的补全,肯定要实现一个 lexer。这个 Lexer 是写一个状态机,还是用一个正则去匹配语法,基本上是每天都在想的问题,也每天在想用正则是不是对的,现在看来,遇到了很多问题,但是多都解决了。

对于这个 lexer 我一开始是写的 Pygments lexer,见这个commit 。但是我后来选择了用一个正则表达式来处理所有的 Redis 命令:任何能match这个正则的字符串就是一个合法的 Redis 命令。为什么呢?因为 Redis Grammar 基本不能叫做一个 Grammar,它基本没有 Grammar。要么是一个 Command 后面跟一个 key,要么是 Command 后跟一个 DB index 这种,如果你写一个 Lexer,你会发现全都是一层扁平的 if-else。

所以我放弃了用Lexer,直接用正则。

这个正则难理解吗?

我觉得不难,首先一个 command 属于一种格式,比如 command + key 是一种,command + ip + port 也是种,我是这么对这些 command 分类的:ip/port/key/fields 这种基本的token定义在最前面,并且每一种格式都会有单元测试覆盖。我觉得无论是维护还是新手理解都是可以的。

这么做的另一个好处是,我可以直接使用正则表达式里面的分组,将token拿到,lexer和 completer 都变得很简单,completer 源代码。但是你可以去看下 mycli 的 completer 。

回到本文中:

这个正则表达式你们自己都看不下去了,所以才会需要使用拼接的方式生成。

这个说的不对,我很冤枉。通过我的 commit log 可以看出,我选择用正则,是一开始就想好了要拼接的,先定义基本的 Token,然后用基本的 Token 定于语法。

费脑子,难以理解,难维护。

这不对,可能读者没有按照我的方式理解,现在这个项目开发比较快,不好写文档(我也没啥时间写),因为都在变。如果我写完了稳定你在看的话就简单多了。首先是 command_syntax.csv 文件,定义了每一个 command 的语法。然后 grammar 里面解释了每一种的语法的组成,按照字面就可以理解。比如 <command-key-field> <key> <field> ,<command-key> <key> 我觉得无论是下面的大正则,还是csv文件,都是比较好理解的。

这个组织是扁平的,其实比 Lexer 好理解、维护不少。

并且每一种语法都带测试,测试已经帮我发现了很多问题。

其实这么做也有一些坑。比如用户输入第一个字符是空格,那么按照我那个正则,空格是“可能匹配”所有的情况的。就导致用户输入第一个空格,整个进程会卡10+s。解决办法是我patch了原生的 Completer,strip() 之后的空字符串不做 completer。(PR

compile 太慢的问题,我想过去缓存 re.compile 的结果(laike9m 的建议,我觉得这个想法太天才了。)。但是最后放弃了,原因见这里

现在这个问题是通过应用在启动的时候新启动一个线程去编译正则,主线程正常接收用户输入来实现的。

还有一个坑是,这个正则其实不是Python本身的正则,断言无法使用,{} 风格的 repeat 无法使用,所以必须用很多 tricky 的方式绕过去。这导致有一些语法树也很难实现,比如这个

但总体上,我觉得相比 Lexer,是比较好维护一些,但是功能弱一些。

 

下面再说一个好玩的东西,比较烧脑,我觉得可以作为一个不错的面试题目:如何实现 Bash 风格的引号处理?要注意双引号里面可以有单引号,单引号里面可以有双引号。双引号里面可以有 \ 转义的双引号,单引号也是。答案在这里。这个 PR 里面也有一个单元测试,有兴趣的同学可以试下能否写一个更优雅的函数,来通过这个单元测试。

最后,这个项目我跟朋友说了之后,得到很多不错的 Feature 建议,未来想要实现的都记录在 issues 里面了,有兴趣的可以看下。如果您有什么想法也可以跟我交流下。主要的目的是实现一个行为和 redis-cli 一致、但是更好用的 redis-cli。

 

附录:本文所谈论的原文相关的部分,这里备份一下,以防读者不知道我在说啥:

多说一句
以下内容与本次讨论的re.compile无关。

@Manjusaka给出了一个compile需要3秒钟的大型正则表达式,并以此作为例子说明re.compile的合理性。

首先这种情况下,确实需要提前re.compile。

但我所想表达的是,在这种情况下,就不应该使用正则表达式。既然要做Redis的语法校验,那么就应该使用有限状态机。这种使用很多的f表达式拼出来的正则表达式,才是真正的难以维护,难以阅读。

否则为什么里面需要用一个csv文件来存放命令呢?为什么不直接写在正则表达式里面呢?使用CSV文件每行一个命令尚且可以理解,但是SLOT/SLOTS/NODE/NEWKWY这些正则表达式,可就说不过去了。或条件连接的每一段都要加上这些东西,如果直接写进去,这个正则表达式你们自己都看不下去了,所以才会需要使用拼接的方式生成。

我在读这段代码的时候,首先看到正则表达式里面的t[xxx],会先去找t是什么东西,发现t是一个字典,字典是在commands_csv_loader.py中生成的,然后去到这个文件里面,发现它读的是一个存放Redis命令的CSV文件。然后去项目根目录读取这个csv文件的内容,知道了它的结构,于是推测出t的结构。然后再回到正则表达式里面,继续看这个超大的正则表达式。整个过程会非常费时间和脑子。

但是,我又不能直接打印REDIS_COMMANDS这个变量,因为它多且乱,不同命令长短不一,拼出来以后再打印出来根本没法看。

这个正则表达式只有两位维护者知道什么意思,如果别人想贡献新的Redis命令,那么理解这个超大正则表达式都需要花很久的时间。

如果换成有限状态机,并且t使用Python的data class来表示,而不是使用字典,那么就会简洁很多。有限状态机的一个特点是,只需要关注当前状态、转移条件和目标状态,可能一开始写起来有点麻烦,但是以后维护和新增,都是直接定位目标,直接修改,不用担心会影响不相干的其他地方。

算上维护时间,正则表达式真是一个非常糟糕的方式。