Rec是一个用于验证和转换数据文件的Java应用程序。从第一行代码到v1版本只用了一个半月。作为一个开源项目,在很多方面都有各种纠结。RequirementRec的需求来源于我们团队正在做的项目的特殊性:遗留系统迁移。工作中,我们需要和各个团队打交道,每天要处理来自ETL(Extract、Transform、Load)过程的各种数据和程序问题,而整个ETL程序运行起来太繁琐,还需要考虑准备后台数据和各种认证问题,很不方便。其实在这之前,只要有一些简单的程序运行和一些简单的校验,比如唯一性、关联性等,就可以大大减少我们花在ETL过程中的时间。而且,过去六个月的实践也印证了这一点。最初同事的建议是写一个脚本文件来解决这个问题,当然这对程序员来说问题不大。但是随着使用次数的增多,我逐渐发现一套Python脚本并不能胜任:一方面,面对复杂的业务场景,很难有一套灵活的模式来匹配所有的数据格式;另一方面,随着数据量的增加,性能成为了一个大问题。所以我着手设计和实施Rec。Rec的第一个可用版本一共花了7天的时间设计出来,它基本上具备了我所期望的所有能力:可定制的数据格式可以进行简单的唯一性和关联验证支持一些扩展的查询语法:例如,可以验证唯一性多领域组合。在性能方面,它基本上能够处理类似于CSV文件的Rec-oriented数据文件格式,包括其他使用分号(;)或竖线(|)作为分隔符的文件。出于习惯,文件的Parser没有选择现成的库,而是按照维基百科和RFC4180的规范自己写的,基本可以解析所有类似的文件。而且还有一个意想不到的发现:也支持以空格为分隔符的文件(比如一些日志)。对于每一条数据,Rec提供了两个组件,一个是数据本身,另一个是数据的存取器。accessor提供了将字段名转换为对应数据项下标的功能:它和SpringBatch中的FieldSetMapper很像,当然在它上面多了一层语法糖。典型的访问器格式如下:firstname,lastname,{5},phone,…,jobtitle,{3}其中“…”表示中间的所有字段都可以忽略,{3}和{5}是占位符,表示中间有那么多字段也是可以忽略的。以“...”划分的两部分也有所不同:其后面的字段使用类似Python的负数下标;也就是说,我不需要知道原始数据有多少个字段,只需要知道我想得到的倒数第二个数是多少就可以了。Rec的验证规则设计的也很简单。由于初始需求只有唯一性校验和关联校验,所以第一版只增加了这两个功能,语法如下:unique:Customer[id]unique:Order[cust_id,prod_id]exist:Order.prod_id,Product.id的每一行代表一个规则,冒号前是规则的名称,冒号后是规则要验证的数据查询表达式。关于查询表达式,这里需要提一下。本来设计的功能比较多,比如filter和combining等,后来扩展的时候发现很难实现更直观易用的语法,于是决定改用嵌入一个来解决脚本引擎。此外,Rec的第一个版本只有Kotlin运行时依赖,所以完整的Jar文件只有2MB。同时,你只要为相应的数据文件提供一个.rec格式的描述文件,然后在同级目录下创建一个default.rule,添加各种检查规则,就可以运行得到你想要的结果.ExtendedRec的第一个版本在某些方面是理想的结果。但是在那之后,我们发现了一些很重要的问题:首先,我们另一层的需求没有得到满足:Rec可以帮助我们验证和发现有问题的数据,但不能按需选择我们想要的内容;其次,在检查数据的同时,我们也隐含着对数据进行整合和转换的需求,这是Rec无法满足的。所以在第一周之后,我开始考虑扩展Rec。第一种是在同事的建议下,把乱七八糟的代码分成了多个模块;其次,考虑增加上面提到的过滤和格式转换的功能。第一步勉强完成了,但我卡在了第二步:对于转换规则,我是否应该将它们与验证规则放在一起?如何区分这两个规则?过滤器中如何设计变量引用等等细节?每一个问题都让我纠结很多,直到我最终决定放弃这一步,直接通过引入脚本引擎来实现:从最初hack嵌入式版本的Kotlin编译器,到决定使用JavaScript,再到放弃Nashorn转而使用Rhino,虽然经历了几次,中间也遇到了很多坑,但毕竟借助成熟的社区经验和引导,还是顺利的下去了。TestDrivenDevelopmentvsTestDrivenDesign事实上,直到现在只有少量的Rec测试。而在拆分模块的时候,因为测试代码之间的依赖关系很多,所以没有拆分,所以基本都集中在一个模块中。当然这也是我自己做项目的一个习惯:不完全按照TDD的方式开发,而是将单元测试作为验证设计思想的手段。因为很多时候,思维的转变太突然了。如果没有意识到,可能下一秒就会彻底改变。而且作为一个简单的工具程序,不需要进行繁重的面向对象设计,因此如何规划设计一个流畅易用的界面就成为了必须要考虑的问题。这时候,测试的设计就更加明显了。另外,像Parser这样的东西,测试是必不可少的,但是如果你要TDD一个Parser,基本上是在给自己找工作。所以这个时候我会先添加一些基本的case来保证功能可以正常实现,然后再引入一些cornercase来保证实际的可用性。对我来说,这完全没有问题:当然,后来的实践也验证了这一点,Rec解析文件从来没有出过问题。KotlinvsJava(Script)最初采用Kotlin是因为它有很多优点,而这些优点也确实影响了Rec的设计,但是因为种种原因,被替换了两次。首先,1.1版本的延迟发布和编码兼容性的诸多问题让我决定用原生Java替换Kotlin。当然,这也导致很多有用的编译时检查和语法糖,以及一个用于bean映射的组件被迫放弃。至于是否采用JavaScript,那是另一个问题。众所周知,JSR223为JVM平台定义了一套脚本引擎规范,但是作为一种强静态类型的编译型语言,Kotlin要符合这个规范还是非常困难的,所以无论是官方实现还是Rec解决方案,两者都不太好:首先您必须启动一个JVM来执行此脚本的操作;在这个动作中,启动第二个JVM调用Kotlin编译器将脚本编译成一个类;然后编译器将使用自定义的类加载器加载并执行该类文件。当所有的功能都集中在一个Jar文件中时,每次都要选择指定classpath等选项,实现起来非常复杂。而且,由于第二次执行的Kotlin编译器无法识别你引入的kotlin-reflect类库(因为已经打包在rec的jar包中),会导致脚本中beanmapper的一些函数不能'根本无法使用。无奈之下,我选择了使用更成熟的JS引擎。当然,选择JS的好处之一就是可以让更多的人拿来用。而且最新的Rhino提供了CommonJS扩展,可以很容易地调用所需的JS文件,在复用和模块化方面也很有用。很多改进。技术选择Rec除了一些Parser相关的代码外,基本都使用了不可变数据结构:一方面是因为使用了Kotlin;另一方面,整个模型中没有涉及更改数据的特殊要求。唯一担心的是内存占用,后来发现这部分担心是不必要的,因为所有内存的瓶颈只在数据文件的Parser上,而项目中的数据项往往有几十个数据项,上百个几千条数据,然后再另外,每次parse都会把一个string拆分成多个,最后合并成一个大的collection。设计之初没有考虑到这一点,很容易爆了JVM堆。这也是后期需要优化的一个方面。还有一点是关于异常处理的。对于Java应用来说,这是一个巨大的坑:异常本身不是问题,但是由于checked和unchecked的区分以及很多不同的设计理念,成为了争论的焦点。这里我参考了JoeDuffy的做法。对于严重的不可重试错误,比如找不到文件、空指针异常、下标错误等,直接让程序死掉(没错,就是PHP中的死掉)。至于数据格式错误等问题,更多的做法是做个笔记,选择继续。当然,这套东西并不依赖于Java的异常系统,只是作为一个设计原则来应用。毕竟这不是App服务器,不需要高可用保证。相反,这种failfast的直接反馈,更有利于发现和解决问题。在类型系统方面,最初实现Rec的语言是Kotlin,它提供了比Java稍微高级的类型系统。当然,重点还是nullable:在功能上,nullable类似于Java8的Optional,用于容纳可空值,可以有效避免空指针异常;在实现方面,略高于Java,不可为null的对象必须被初始化并且不允许为null。这就直接解决了Optional对象为空的尴尬问题。当然,由于运行时的依赖,还是不可避免的要使用JVM,不支持自定义值类型。在使用Kotlin的时候,尤其是结合Java标准库等框架,还是会遇到空指针的坑。但在这一点上,Kotlin给了我们一个良好的开端。比如在后面转Java的过程中,我也尽量保证所有的对象都是final的,并且初始化非null。结语当然,很多人可能会说,如果Unix工具好用,上面说的都不是问题。其实Rec最初的想法也来自他们:accessor来自awk的column操作方式,scripting中的filter来自sed和grep,chainedcalls来自pipes。Rec只是在这些想法之上添加了一些方便的操作。但是对于我个人来说,这种折腾其实就是在考验自己的理论和思维,更谈不上提高项目的生产力了。也许有一天我受不了了,我可以用C++和Lua重写。人生终究是无尽的,折腾的。【本文为专栏作者“ThoughtWorks”原创稿件,微信公众号:Thinkworker,转载请联系原作者】点此查看该作者更多好文
