你知道如何写一个框架吗?详细步骤放送
在这之前我们写的框架只能说是一个在最基本的情况下可以使用的框架,作为一个框架我们无法预测开发人员将来会怎么使用它,所以我们需要做大量的工作来确保框架不但各种功能都是正确的,而且还是健壮的。写应用系统的代码,大多数项目是不会去写单元测试的,原因很多:
- 项目赶时间,连做一些输入验证都没时间搞,哪里有时间写测试代码。
- 项目对各项功能的质量要求不高,只要能在标准的操作流程下功能可用即可。
- 项目基本不会去改或是临时项目,一旦测试通过之后就始终是这样子了,没有迭代。
- ……
对于框架,恰恰相反,没有配套的单元测试的框架(也就是仅仅使用人工的方式进行测试,比如在main中调用一些方法观察日志或输出,或者运行一下示例项目查看各种功能是否正常,是非常可怕的)原因如下:
- 自动化程度高,回归需要的时间短,甚至可以整合到构建过程中进行,这是人工测试无法实现的。
- 框架一定是有非常多的迭代和重构的, 每一次修改虽然只改了A功能,但是可能会影响到B和C功能,人工测试的话你可能只会验证A是否正常,容易忽略B和C,使用单元测试的话只要所有功能都有覆盖,那么几乎不可能遗漏因为修改导致的潜在问题,而且还能反馈出来因为修改导致的兼容性问题。
- 之前说过,一旦框架开放出去,框架的使用者可能会以各种方式在各种环境来使用你的框架,环境不同会造成很多怪异的边界输入或非法输入,需要使用单元测试对代码进行严格的边界测试,以确保框架可以在严酷的环境下生存。
- 单元测试还能帮助我们改善设计,在写单元测试的时候如果发现目标代码非常难以进行模拟难以构建有效的单元测试,那么说明目标代码可能有强依赖或职责过于复杂,一个被单元测试高度覆盖的框架往往是设计精良的,符合高内聚低耦合的框架。
如果框架的时间需求不是特别紧的话,单元测试的引入可以是走通主线流程的阶段就引入,越早引入框架的成熟度可能就会越高,以后重构返工的机会会越小,框架的可靠性也肯定会大幅提高。之前我有写过一个类库项目,并没有写单元测试,在项目中使用了这个类库一段时间也没有出现任何问题,后来花了一点时间为类库写了单元测试,出乎我意料之外的是,我的类库提供的所有API中有超过一半是无法通过单元测试的(原以为这是一个成熟的类库,其实包含了数十个BUG),甚至其中有一个API是在我的项目中使用的。你可能会问,为什么在使用这个API的时候没有发生问题而在单元测试的时候发生问题了呢?原因之前提到过,我是框架的设计者,我在使用类库提供的API的时候是知道使用的最佳实践的,因此我在使用的时候为类库进行了一个特别的设置,这个问题如果不是通过单元测试暴露的话,那么其它人在使用这个类库的时候基本都会遇到一个潜在的BUG。
示范项目
写一个示例项目不仅仅是为了给别人参考,而且还能够帮助自己去完善框架,对于示例项目,最好兼顾下面几点:
- 是一个具有一定意义的网站或系统,而不是纯粹为了演示特性而演示。这是因为,很多时候只有那些真正的业务逻辑才会暴露出问题,演示特性的时候我们总是有一些定势思维会规避很多问题。或者可以提供两个项目,一个纯粹演示特性,一个是示例项目。
- 覆盖尽可能多的特性或使用难点,在项目的代码中提供一些注释,很多开发人员不喜欢阅读文档,反而喜欢看一下示例项目直接上手(模仿示例项目,或直接拿示例项目中的代码来修改)。
- 项目中的代码,特别是涉及到框架使用的代码一定要规范,原因上面也说了,作为框架的设计者你不会希望大家复制的代码粘帖的代码一团糟吧。
- 如果你的项目针对的不仅仅是Web项目,那么示例项目最好提供Web和桌面两个版本,一来你自己容易发现因为环境不同带来的使用差异,二来可以给予不同类型项目不同的最佳实践。
完善日志和异常
一个好的框架不但需要设计精良,日志和异常的处理是否到位也是非常重要的标准,这里有一些反例:
- 日志的各种级别的使用没有统一的标准,甚至是永远只使用某个级别的日志。
- 几乎没有任何的日志,框架的运行完全是一个黑盒。
- 记录的日志多且没有实际含义,只是调试的时候用来观察变量的内容。
- 异常类型只使用Exception,不使用更具体化的类型,没有自定义类型。
- 异常的消息文本只写"错误"字样,不写清楚具体的问题所在。
- 永远只是抛出异常,让异常上升到最外层,交给框架的使用者去处理。
- 用异常来控制代码流程,或本应该在方法未达到预期效果的时候使用异常却使用返回值。
其实个人觉得,一个框架的主逻辑代码并不一定是最难的,最难的是对一些细节的处理,让框架保持一套规范的统一的日志和异常的使用反而对框架开发者来说是一个难点,下面是针对记录日志的一些建议:
1、首先要对框架使用的日志级别有一个规范,比如定义:
- DEBUG:用于观察程序的运行流程,仅在调试的时候开启
- INFO:用于告知程序运行状态或阶段的变化,可以在测试环境开启
- WARNING:用于告知程序可以自己恢复的错误或异常,或不影响主线流程执行的错误或问题,可以在正式环境开启
- ERROR:用于告知程序无法恢复,主线流程中断,需要开发或运维人员知晓干预的错误或异常,需要在正式环境开启
2、按照上面的级别规范,在需要记录日志的地方记录日志,除了DEBUG级别的日志其它日志不能记录过多,如果框架总是在运行的时候输出几十个WARNNING也容易让使用者忽略真正的问题。
3、日志记录的消息需要是明确的,最好包含一些上下文信息,比如"无法在xxx下找到配置文件xxx.config,框架将采用默认的配置",而不是"加载配置失败!"
下面是一些针对使用异常的建议:
- 框架由于配置错误或使用错误或运行错误,不能完成API名字所表示的功能,考虑抛出转化后的异常,让调用者知道发什么了什么情况,同时框架可以建立自己的错误处理机制
- 对于可以预料的错误,并且错误类型可以枚举,考虑以返回值的形式告知调用者可以根据不同的结果来处理后续的逻辑
- 对于框架内部功能实现上遇到的调用者无能力解决的错误,如果错误可以重试或不影响返回,可以记录警告或错误日志
- 可以为每一个模块都陪伴自定义的异常类型,包含相关的上下文信息(比如ViewException可以包含ViewContext),这样出现异常可以很方便知晓是哪个模块出现问题并且可以得到出现异常时的环境信息
- 如果异常跨了实现层次(比如从框架到应用),那么最好进行一下包装转换(比如把文件读取失败的提示改为加载配置文件失败的提示),否则上层人员是不知道怎么处理这些内部问题的,内部问题需要由框架自己来处理
- 异常的日志中可以记录和当前操作密切相关的参数信息,比如搜索的路径,视图名等等,有关方法的信息不用过多记录,异常一般都带有调用栈信息
- 如果可能的话,出现异常的时候可以分析一下为什么会出现这样的问题,在异常信息中给一些解决问题的建议或帮助链接方便使用者排查问题
- 异常处理从坏到好的层次是,出现了严重问题的时候:
- 使用者什么都不知道,程序的完整性和逻辑得到破坏
- 使用者既不知道出现了什么问题也不知道怎么去解决
- 使用者能明确知道出现了什么问题,但无法去解决
- 使用者不但知道发生了什么,还能通过异常消息的引导快速解决问题
完善配置
配置的部分可以留到框架写的差不多了再去写,因为这个时候已经可以想清楚哪些配置是:
- 需要公开出去给使用者配置的,并且配置会根据环境不同而不同
- 需要公开出去给使用者来配置的,配置和部署环境无关
- 仅仅需要在框架内供框架开发人员来配置的
- 无需是一个配置,只要在代码中集中存储这个设定即可
一般来说配置有几种方式:
- 通过配置文件来配置,比如XML文件、JSON文件或property文件
- 通过注解或特性(Annotation/Attribute)方式(对类、方法、参数)进行配置
- 通过代码方式进行配置(比如单独的配置类,或实现配置类或调用框架的配置API)
很多框架提供了多种配置方式,比如Spring MVC同时支持上面三种方式的配置,个人觉得对配置,我们还是应该区别对待,而不是无脑把所有的配置项都同时以上面三种方式提供配置,我们要考虑高内聚和低耦合原则,对于Web框架来说,高内聚需要考虑的比低耦合更多,我的建议是对不同的配置项提供不同的配置方式:
- 如果配置项目是需要让使用者来配置的,特别是和环境相关的,那么最好使用配置方式来配置,比如开放的端口、内存、线程数配置,不过要注意:
- 所有配置项目需要有默认值,如果找不到配置使用默认值,如果配置不合理使用默认值(你不会希望使用你框架的人把框架内部的线程池的min设置为999999,或定时器的间隔设置为0毫秒吧?)
- 框架启动的时候检测所有配置,如果不合理给予提示,大多人只会在启动的时候看一下日志,使用的时候根本就不管
- 不知道大家对于配置文件的格式倾向于XML呢还是JSON呢还是键值对呢?
- 对于所有仅在开发时进行的配置,都尽量不要去使用配置文件,并且让配置尽量和它所配置的对象靠在一起:
- 如果是对框架整体性进行的设置扩展类型的配置,那就可以提供代码方式进行配置,比如我们要实现的MVC框架的各种IRoute、IViewEngine等,最好可以提供IConfig接口让开发人员可以去实现接口,这样他们可以知道有哪些东西可以配置,代码就是文档
- 如果是那种对模型、Action进行的配置,比如模型的验证规则、Filter等一律采用注解的方式进行配置
- 有的人说使用配置文件进行配置非常灵活,使用代码方式和注解方式来配置不灵活而且可能有侵入性。我觉得还是要权衡对待,我的建议是不要把太多框架内在的东西放在配置文件中,增加使用者的难度(而且很多时候,大多数人只是复制配置为了完成配置而配置,并不是为了真正的灵活性而去使用配置文件来配置你的框架,看看网上这么所SSH配置文件的抄来抄去就知道了)。
- 最后,我建议很多太内部的东西对于轻量级的应用型框架可以不去提供任何配置选项,只需要在某个常量文件中定义即可,让真正有需求进行二次开发的开发人员去修改,对于一个框架如果一下子暴露上百个"高级"配置项给使用者,他们会晕眩的。
提供状态服务
所谓状态服务就是反映框架内部运作状态的服务,很多开源服务或系统(Nginx、Mongodb等)都提供了类似的模块和功能,作为框架的话我觉得也有必要提供一些内部信息(主要是配置、数据统计以及内部资源状态)出来,这样使用你框架的人可以在开发的时候或线上运作的时候了解框架的运作状态,我们举两个例子,对于一个我们之前提到的Web MVC框架来说,可以提供这些信息:
- 路由配置
- 视图引擎配置
- 过滤器配置
对于一个Socket框架来说,有一些不同,Socket框架是有状态的,其状态服务提供的信息除了当前生效的配置信息之外,更多的是反映当前框架内部一些资源的状态以及统计数据:
- 各种配置(池配置、队列配置、集群配置)
- Socket相关的统计数据(总打开、总关闭、每秒收发数据、总收发数据、当前打开等等)
- 各种池的当前状态
- 各种队列的当前状态
状态服务可以以下面几种形式来提供:
- 代码方式,比如如果开发人员实现了IXXXStateAware接口的话,就可以为它的实现类来推送一些信息,也可以直接在框架中设立一个StateCenter来公开框架所有的状态信息
- 自动日志方式,比如如果在配置中开启了stateLoggingInterval=60s的选项,我们的框架就会自动一分钟一次输出日志,显示框架内部的状态
- 接口方式,比如开放一个Restful的接口或额外监听一个端口来提供状态服务,方便使用者可以拿原始的数据和其它监控平台进行整合
- 内部外部工具方式
- 比如我们可以直接为框架提供一个专门的页面(/_route)来呈现路由的配置(甚至我们可以在这个页面上让开发人员可以直接输入地址来测试路由的匹配情况,状态服务不一定只能看),这样在开发和测试的时候可以更方便调试
- 我们也可以为框架提供一个专有工具来查看框架的状态信息(当然,这个工具其实可能就是连接框架的某个网络服务来获取数据),这样即使框架在多个机器中使用,我们可能也只有一个监控工具即可
- 如果没有状态服务,那么在运行的时候框架就是一个黑盒,反之如果状态服务足够详细的话,可以方便我们排查一些功能或性能问题。不过要注意的一点是,状体服务可能会降低框架的性能,我们可能需要对状态服务也进行一次压测,排除状态服务中损耗性能的地方(有些数据的收集会意想不到得损耗性能)。
检查线程安全
框架对多线程环境支持的是否好,是框架质量的一个重要的评估标准,往往可以看到甚至有一些成熟的框架也会有多线程问题。这里涉及几个方面:
1,你无法预料框架的使用者会怎么样去实例化和保存你的API的入口类,如果你的入口类被用成为了一个单例,在并发调用的情况下会不会有单线程问题?
这是一个老话题,之前已经说过很多次,你在设计框架的时候心里如果把一个类定位成了单例的类但却没有提供单例模式,你是无法要求使用者来帮你实现单例的。这其中涉及的不仅仅是多线程问题,可能还有性能问题。比如见过某分布式缓存的客户端的CacheClient在文档中要求使用者针对一个缓存集群保持一个CacheClient的单例(因为其中有了连接池),但是用的人还是每一次都实例化了一个CacheClient出来,几小时后就会产生几万个半死的Socket导致网络奔溃。又见过某类库的入口工厂的代码注释中写了要求使用的人把XXXFactory作为单例来使用(因为其中缓存了大量数据),但是用的人就没有注意到这个注释,每一次都实例化了一个XXXFactory,造成GC的崩溃。所以我觉得作为框架的设计者开发人员,最好还是把框架的最佳实践直接做到API中,使得使用者不可能出错(之前说过一句话,再重复一次,好的框架不会让使用的人犯错)。你可能会说对于CacheClient的例子,不可能做成单例的,因为我的程序可能需要用到多个缓存的集群,换个思路,我们完全可以在封装一层,通过一个CacheClientCreator之类的类来管理多个单例的CacheClient。即使在某些极端的情况下,你不能只提供一条路给使用者去走,也需要在框架内做一些检测机制,及时提醒使用者 "我们发现您这样使用了框架,这可能会产生问题,你本意是否打算那样做呢?"
2,如果你的入口类本来就是单例的,那么你是类中是否持有共享资源,你的API在并发的情况下被调用是否可以确保这些资源的线程安全?在解决多线程问题的时候往往有几个难点:
百密难有一疏,你很难想到这段代码会有人这样去并发调用。比如某init()方法,某config()方法,你总是假设使用者会调用并且仅调用一次,但事实不一定这样,有的时候调用者自己也不清楚我的容器会调用我这段代码多少次。
好吧,解决多线程问题各种烦躁,那就对各种涉及到共享资源的方法全部加锁。对方法进行粗犷(粒度)的锁可能会导致性能急剧下降甚至是死锁问题。
自以为使用了优雅的无锁代码或并发容器但却达不到目的。我们往往在大量使用了并发集合心中暗自窃喜解决了多线程问题的同时又达到了极佳的性能,但你以为这样是解决了线程安全问题但其实根本就没有,我们不能假设A和B都方法是线程安全的,但对A和B方法调用的整个代码段是线程安全的。
对于多线程问题,我没有好的解决办法,不过下面的几条我觉得可以尝试:
需要非常仔细的过一遍代码,把涉及到共享资源的地方,以及相关的方法和类列出来,不要去假设什么,只要API暴露出去了则假设它可能被并发调用。共享资源不一定是静态资源,哪怕资源是非静态的,在并发环境下对相同对象的资源进行操作也可能产生问题。
一般而言对于公开的API,作为框架的设计者我们需要确保所有的静态方法(或但单例类的实例方法)是线程安全的,对于实例方法我们可以不这么做(因为性能原因),但是需要在注释中明确提示使用者方法的非线程安全,如果需要并发调用请自行处理线程安全问题。
可以看看是否有可能让这些资源(字段)变为方法内的局部变量,有的时候我们并不是真正的需要类持有一个字段,只是因为多个方法要使用相同的东西,随手一写罢了。
对于使用频率低的一些方法相关的一些资源没有必要使用并发容器,直接采用粗狂的方式进行资源加锁甚至是方法级别加锁,先确保没有线程安全,如果以后做压测出现性能问题再来解决。
对于使用频率高的一些方法相关的一些资源可以使用并发容器,但需要仔细思考一下代码是否会存在线程安全问题,必要的话为代码设计一些多线程环境的单元测试去验证。
性能测试和优化
之前也提到过,你不会预测到你的项目会在怎么样的访问量下使用,我们不希望框架和同类的框架相比有明显的性能差距(如果你做的是一个ORM框架或RPC框架,这个工作就是必不可少的),所以在框架基本完成后我们需要做Benchmark:
- 确定几个测试用例,尽量覆盖主流程和一些重要扩展
- 找几个主流的同类型框架,实现相同的测试用例,实现到时候要单纯一点,尽量不要再依赖其它外部框架
- 为这些框架和自己的框架,使用压力测试工具在相同的环境和平台来跑这些测试用例,使用图表绘制在不同的压力下的执行时间(以及内存和CPU等主要资源的消耗情况)
- 如果出现明显的差距则用性能分析工具进行排查和优化,比如:
- 优化框架内的线程安全的实现方式
- 为框架内的代码做一些缓存(缓存反射得到的元数据等等)
- 减少调用层次
- 这些调整可能会打破原来的主线流程或让代码变得难以理解,需要留下相关注释
- 不断重压力测试和优化的过程,每次尝试优化5%~20%的性能,虽然越到后来可能会越难,如果发现实在无法优化的话(性能分析工具显示性能的分布已经很均匀了),可以看一下其它框架对于这部分工作实现的代码逻辑
封装和扩展
个人觉得一个框架如果只是能用那是第一个层次,能很方便的进行扩展或二次开发那是另外一个层次,如果我们龙骨阶段的工作做的足够好,框架是一个立体饱满的框架,那么这部分的工作量就会小很多,否则我们需要对框架进行不少的重构以便可以达到这个层次。
- 我们需要纵览一下框架的所有类型,看看有哪些类型我们是打算提供开发人员进行增强、扩展或替换的,对这些类型进行响应的结构调整。
- 比如希望被增强,则需要从继承的角度来考虑
- 比如希望被扩展,则需要从Provider的角度来考虑
- 比如希望被替换,则需要在配置中提供组件的替换
- 我们需要再为这些类型进行精细化的调整:
- 检查是否该封闭的封闭了,该开放的开放了
- 增强扩展或替换是否会带来副作用
- 对于新来的外来类型,接收和使用的时候做足够的检查
- 相关日志的完善
重构还是重构
光是重构这个事情其实就可以说一本书了,其实我有一点代码的洁癖,这里列一些我自己写代码的时候注重的地方:
- 格式:每次提交代码的时候使用IDE来格式化你的代码和引用(当然,实现可能需要配置IDE为你喜欢的代码风格)
- 命名:保持整个类和接口命名统一,各种er,Provider、Creator、Initializer、Invoker、Selector代表的是一件事情,不要使用汉语拼音命名,如果英文不够好的话多查一下字典,有的时候我甚至会因为一个命名去阅读一些源代码看看老外是怎么命名这个对象或这个方法的
- 访问控制修饰符:这是一个非常难做好的细节,因为有太多的地方有访问控制修饰符,究竟是给予什么级别的修饰符往往又取决于框架的扩展。可以在一开始的时候给尽量小的权限,在必要的时候慢慢提升,比如对于方法除了一定要给public的地方(比如公共API或实现接口),尽量都给private,在有继承层次关系的时候去给到protected,对于类可以都给默认包/程序集权限,产生编译错误的时候再去给到public
- 属性/getter、setter:对于非POJO类字段的公开也要仔细想一下, 是否有必要有setter,因为一旦外部可以来设置类的某个内部字段,那么不仅仅可能改变了类的内部状态,你还要考虑的是怎么处理这种改变,是不是有线程安全问题等等,甚至要考虑是否有必要开放getter,是否应该把类内部的信息公开给外部
- 方法:思考每一个方法在当前的类中存在是否合理,这是否属于当前类应该做的事情,方法是否做了太多事情太少事情
- 参数:需要思考,对于调用每一个方法的参数,应该是传给方法,还是让方法自己去获取;应该传多个参数,还是封装一个上下文给到方法
- 常量:尽量用枚举或静态字符串来代替框架使用到的一些常量或幻数,需要为常量进行一个分类不能一股脑堆在一个常量类Consts中
除了上面说的一些问题,我觉得对于重构,最重要的一句话就是:不要让同一段代码出现两遍,主要围绕这个原则进行重构往往就会解决很多设计问题,要实现这个目标可能需要:
- 干差不多活的类使用继承来避免代码重复(提炼超类),使用模版方法来把差异留给子类实现
- 构造方法可以层次化调用,主构造方法只要一个就可以了,不要在构造方法中实现太多逻辑
- 如果方法的代码有重复可以考虑对方法提取出更小的公共方法来调用(提炼方法),也可以考虑使用Lambda表达式进行更小粒度重复代码的提取(提炼逻辑)
- 可以使用IDE或一些代码分析工具来分析重复代码,如果你能想尽一切办法来避免这些重复的话,代码质量可以提高一个层次
其实也不一定是在重构的时候再去处理上面所有的问题,如果在写代码的时候都带着这些意识来写的话那么重构的负担就会小一点(不过写代码思想的负担比较大,需要同时考虑封装问题、优雅问题、日志异常问题、多线程问题等等,所以写一套能用的代码和写一套好的代码其实不是一回事情)。
项目文档
如果要别人来使用你的框架,除了示例项目来说提供和维护一份项目文档是很有必要的,我建议文档分为这几个部分:
- 特性 Features:
- 相当于项目的一个宣传手册,让别人能被你项目的亮点所吸引
- 每一个特性可以是一句话来介绍
- 新手入门 Get started:
- 介绍框架的基本定位和作用
- 从下载开始,通过一步一步的方式让用户了解怎么把框架用起来
- 整个文档的阅读时间在10分钟以内
- 新手教程 Tutorials:
- 提供5~10篇文章站在使用者的角度来介绍项目的主要功能点
- 还是通过一步一步的方式,教大家使用框架完成一个小项目(比如CRUD)
- 介绍框架使用的最佳实践
- 整个文档的阅读时间在8小时内
- 手册 Manual:
- 介绍项目的定位和理念
- 详细介绍项目的每一个功能点,可以站在框架设计者的角度多介绍一些理念
- 详细介绍项目的每一个配置,以及默认配置和典型配置
- 详细介绍项目的每一个扩展点和替换点
- 文档最好不是带格式的,方便以后适配各种文档生成器和开源网站
开源
开源的好处是有很多人可以看到你的代码帮助你改进,你的框架也可能会在更多的复杂环境下使用,框架的发展会较快框架的代码质量也会有很大的提升。
要把框架进行开源,除了上面的各种工作之外可能还有一些额外的工作需要做:
- 选择一个合适的License,并且检测自己选择的License与使用到的类库的License是否兼容,在代码头的地方标记上License。
- 要确保每一个人都可以在自己的环境中可以构建你的代码,尽量使用Maven等大家熟悉的构建工具来管理依赖和构建。
- 选择诸如Github等平台来管理源代码,并以良好的格式上传你的文档,有条件的话对示例子网站进行部署。
- 如果你希望你的代码让更多的人一起来参与开发,那么需要制定和公开一些规范,比如风格、命名、提交流程、测试规范、质量要求等等。
- 开源后时刻对项目进行关注,对各种反馈和整合请求进行及时的反馈,毕竟开源是让别人来帮你一起改进代码,不是单纯让别人来学习你的代码也不是让别人来帮你写代码。
看到这里你可能相信我一开始的话了吧,框架可以使用到完善可以商用差距还是很大的,而且还要确保在迭代的过程中框架不能偏离开始的初衷不能有很大的性能问题出现,任重道远。