加入rds proxy不到一个月,有太多的东西需要重新学习,这篇博客作为对上一个月的总结(我觉得一个月总结一次蛮不错的,N年后可以看看这些年的成长路径)。
9月初加入到rds团队,开始从C++切换到erlang,一边学习新语言言,一边泛泛地看了一遍主流程的代码。中间还蹭了rds团队的outing。
rds proxy的进程模型很像hotwheel。共同点是:他们都是自己不做数据计算,都是把数据转发。
只不过rds proxy是一个商业项目,业务上要复杂的多; 而hotwheel是一个toy的开源项目。
两者都是对每一个client的连接,创建两个进程,一个做为client的代理人,一个做为server的代理人。client对真正server的数据交互,在rds上就是这两个进程的交互,因为他们代表了各自的实体。
对rds proxy的非正确理解
代码复杂度
rds proxy有固有的复杂度。
在加入rds proxy团队之前,粗略的看了ranch,cowboy,hotwheel的代码。这几个开源项目在rds proxy面前太简单了。
rds proxy是如何复杂的呢:它里面需要解析mysql变态的协议,如何握手,协商加解密,处理mysql所有的command,所有的sql语句,如何流控,如何做sql的安全,还有主备的切换,还有分库分表。。。
还有大量我暂时不知道的功能。
每一个交互都是一个状态机,每个环节都会出错。业务上的复杂导致rds proxy代码相当的复杂。
代码的模块化
在如此高的复杂下,rds proxy做了模块化设计,必须这么做。每个功能点都是一个独立的模块比如网络模块,协议处理模块,api模块,分库分表等,监控模块。
在OTP的提供的便利上,把业务做成多个supervisor监控的进程树。
这也是顺其自然的事,没有出色的地方。
代码级别一些非正确的理解
从高层上看代码的模块化已经很棒了,为了追求完美,有一些地方可以做的更好: 1) 框架代码和业务代码还存在一定的耦合度。做为一个proxy server,连接client和连接server应该使通用的,协议无关的。 2) 有的函数体积有点大,尽量使用函数特化代替cond,if,使得函数小而美等。 3) 能否把mysql的协议细节切分到多个模块中呢,看着数千行的协议代码都恐惧???而且改动起来真的是手抖阿。 4) 制定出一套编码约定:比如所有的数据全部用binary类型(Username之前都是字符串类型后来改成了binary); 变量命名方式是使用下划线,还是连在一块微软的命名方式。。。 5) 测试:目前还没有完善的单测,集成测试,系统测试等。这也是正在做的事情。 6) 类型系统:被exported出来的函数,声明类型。完善rds proxy的类型系统,借助analyzer帮助发现问题。 7) 从连接被accept到真正的建立进程路径太长,影响并发。
由于erlang和业务不熟悉,上述观点仅仅是当前的理解,目的是做为后续优化的灵感来源。
另外,最近3周在冲刺rds proxy中的mysql协议的单元测试。和mysql协议处理相关的模块逻辑很复杂,但是这些模块都是没有状态的,加上elrang的函数式特点,非常容易做单元测试。
做为一篇测试技术的总结,还是要罗唆的说一些测试相关的理论。
单元测试
对测试的理解
测试的分类:系统测试,集成测试,性能测试,单元测试,heartbeat等。前面几种测试都有测试同学的参与,同时开发同学也有相应的参与; 单元测试主要是开发同学完成。
测试同学是从产品质量的角度出发,对一个系统进行黑盒测试,保证产品的可用性,稳定性。
单元测试在函数式语言中的作用
单元测试是由开发同学深入代码,基于自己对代码的了解,对模块中的每一行代码进行测试。
设计单元测试的时候要做到:
1) 单元测试的能够保证一个模块提供的功能是正确无误的。即覆盖率要高,业界建议是80%以上,100%确实很难做到。 2) 单元测试的用例能够起到回归测试的作用。代码的重构其实比最初设计压力还要大,改的越多越心虚。因为这个时候的代码已经在线上为用户服务了,重构要达到重构目标的同时,最基本的还要保证重构后的代码逻辑和之前是一致的。 这个时候如果能够由足够多的测试用例做回归,在重构过程中进行回归测试,会放心的多。 3) TDD,测试驱动开发。TDD在函数式语言的开发中是合适的,因为函数式语言的开发过程就是对一个个小函数拼装的过程。当模块通过了设计的单元测试用例,也就开发完成了。而且这些测试用例为以后的重构提供了保障,何乐不为呢?
elang 中的单元测试框架 eunit
eunit是从其他语言的单元测试框架(JUnit,SUnit, CPPUnit)中获取灵感。都提供了一些断言的宏,然后对测试的结果进行收集和统计。
功能是一样的,关键的是如何做到好用。
http://www.erlang.org/doc/apps/eunit/chapter.html
eunit的使用
方式一 单测代码放在模块后面(如cowbow)
准备工作(头文件)
使用eunit最简单的方式就是把测试代码放在被测试代码模块文件的可后面(如cowboy),需要包含eunit的头文件
-include_lib("eunit/include/eunit.hrl").
这个文件3个作用: 1) 创建一个被exported的函数text(),这个函数会调用所有测试用例。 2) 使所有函数名为..._test() 或者 ..._test_()函数被exported。 3) 定义eunit中所有的预处理宏。 注意eunit.hrl要在erlang的搜索路径里面。
开始使用eunit
eunit会自动的调用所有..._test()的函数,返回值会自动丢掉。
最简单的测试用例 - 验证是否crash
reverse_test() -> lists:reverse([1,2,3]).
这个测试了lists:reverse在输入参数是[1,2,3]时,是否能够正常返回,并没有对返回值做任何的期望。仅仅期望这个函数不要crash。
reverse_nil_test() -> [] = lists:reverse([]). reverse_one_test() -> [1] = lists:reverse([1]). reverse_two_test() -> [2,1] = lists:reverse([1,2]).
这个3个测试,对lists:reverse的返回结果做了期望,如果不符合就会match失败,抛出异常。
使用断言宏
eunit和其他语言一样提供了各种断言宏
length_test() -> ?assert(length([1,2,3]) =:= 3).
这是一个对boolean值的断言,如果表达式返回的不是true,则抛出一个异常。
运行单元测试
通过include的方式,eunit.hrl已经把test()和所有..._test()函数exported出来了,可以直接运行。 加入你的模块名字是m,那么有2种方式运行m中的单测: 1) m:test(). 2) eunit:test(m).
方式二 把单测代码单独放置在另一个文件里
对模块m做单元测试,eunit会自动寻找m_tests()模块,并运行其中的测试代码。
eunit对标准输出的捕获
eunit对测试代码中的标准输出进行了捕获,只有当测试用例失败的时候才会把你的输出一起打印出来。 推荐使用eunit提供的宏打印调试中的信息。Debugging macros
eunit test as data
对一些简单的方法编写单测,eunit提供了方便的生成器,方法...test_()的返回fun列表会逐一被执行。
eunit的关闭
eunit可以关闭,只要定义NOTEST宏。如,
erlc -DNOTEST my_module.erl
或者在include Eunit头文件之前显示的定义:
-define(NOTEST, 1).
开启eunit:
erlc -DTEST my_module.erl
避免对测试代码的编译
当你把你的项目开源出去的时候,不希望测试代码被使用者编译,可以使用宏把所有的测试代码包起来。
-ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif.
erlang中mock框架 meck
单元测试过程中有时候需要外部模块的配合,而外部模块的行为又不要控制,这个时候就需要自己动手写‘桩模块’。
erlang中的meck就是这样一个框架。
https://github.com/eproxus/meck/blob/master/src/meck.erl
meck的使用
入门级使用
Eshell V5.8.4 (abort with ^G) 1> meck:new(dog). ok 2> meck:expect(dog, bark, fun() -> "Woof!" end). ok 3> dog:bark(). "Woof!" 4> meck:validate(dog). true 5> meck:unload(dog). ok 6> dog:bark(). exception error: undefined function dog:bark/0
可以看到meck的使用很简单: 1) meck:new(dog) 先装载一个模块dog。 2) meck:expect(dog, bark, fun() -> "Woof!" end) 声明dog中的bark函数被调用的时候,执行fun() -> "Woof!" end。 3) meck:unload(dog)。使用完之后卸载dog模块。 这样在调用dog:bark()的时候,就调用了meck的函数。
meck模拟异常
meck可以模拟一个函数抛出异常,以此达到调用模块对异常的处理能力。
5> meck:expect(dog, meow, fun() -> meck:exception(error, not_a_cat) end). ok 6> catch dog:meow(). {'EXIT',{not_a_cat,[{meck,exception,2}, {meck,exec,4}, {dog,meow,[]}, {erl_eval,do_apply,5}, {erl_eval,expr,5}, {shell,exprs,6}, {shell,eval_exprs,6}, {shell,eval_loop,3}]}} 7> meck:validate(dog). true
mock部分函数
在meck:new(dog)的时候,会隐藏掉dog模块中所有的函数。 通过传入passthrough可以使meck仅隐藏被expect的函数。
Eshell V5.8.4 (abort with ^G) 1> meck:new(string, [unstick, passthrough]). ok 2> string:strip(" test "). "test"
也可以在expect中调用passthrough/1 比如,
Eshell V5.8.4 (abort with ^G) 1> meck:new(string, [unstick]). ok 2> meck:expect(string, strip, fun(String) -> meck:passthrough([String]) end). ok 3> string:strip(" test "). "test" 4> meck:unload(string). ok 5> string:strip(" test "). "test"
更好的选项可以直接察看meck.erl。
meck 一个函数的多个返回结果
meck:sequence可以指定一个列表,使得对同一个函数的多次调用,返回不同的结果。
Ret = ["woof1", "Woof2", "Woof3"], meck:new(dog, [passthrough]), meck:sequence(dog, bark, 0, Ret), dog:bark(), % "Woof1" dog:bark(), % "Woof2" dog:bark(), % "Woof3"
更多的使用技巧直接看meck的代码吧。