Ruby on Rails近几年在国内受到越来越多的开发者的青睐,Rails应用也从较简单的内部系统深入到复杂的企业级应用。Rails“习惯优于配置”的思想以及ActiveRecord等众多优秀的技术极大地提高了开发效率,但在业务复杂的大型系统中,Rails应用也会面临很多问题。
\本文将介绍一种Rails系统重构方案,将复杂的Rails单一系统拆分成相互协作的多个轻量级应用集群,从根本上解决Rails系统在处理复杂的业务时代码臃肿、开发效率降低、难以维护与部署等问题。
\复杂Rails系统存在的问题
\用Rails可以快速搭建一个较简单的应用,但当业务需求急剧增长,功能越来越复杂时,系统的维护和扩展会变得越来越困难。一般情况下,问题主要表现在以下几个方面:
\代码臃肿
\我们知道Rails提倡RESTful架构,所以良好的代码组织方式是对每一个实体(Model)都有相应的处理器(Controller)来进行操作,而Rails每个Controller都有一个相应的Helper以及若干个View。这样当系统功能比较复杂时,代码量会急剧增大。
\首先是Model的数量会很多。由于Model都放在app/models目录下面,而Rails又不支持Model分目录,虽然可以通过修改load_paths解决这一问题,但有时(如目录与Model重名)会造成难以调试的错误,所以当Model数量比较多时,这个目录会变得难以管理。
\其次是Controller的目录层级会比较深。为保持清晰的代码结构,Controller应该按照功能分类存放在不同的目录下。所以当功能比较多时,很容易出现四五层甚至更深的目录结构,这不仅使代码难以管理,routes的配置和解析也变得很复杂,例如会出现level1_level2_level3_level4_controller_name_path这样很长的routes Helper方法。
\难以测试与部署
\复杂的业务代码不仅会增加写测试代码的难度,运行测试的时间也必然会随之增加。大量的fixtures不仅难以管理,还经常会造成互相干扰。
\复杂的系统也增加了部署的风险,一个小错误可能会导致整个系统的崩溃。为了降低这种风险,需要延长系统部署的周期,只在特定的时间或系统有重大更新时才部署,这样就在一定程度上弱化了Rails系统根据用户需求快速升级迭代的优点。
\影响团队建设
\除了技术上的问题,复杂的Rails系统对团队建设也会产生不利影响。首先如果某个开发人员提交了测试无法通过的代码,会对其他人的工作产生影响,降低开发效率;其次对于复杂的系统,增加新功能或修复bug都变得比较困难,久而久之程序员就会产生惰性,代码能少改的就少改,严重阻碍了系统的快速进化;最后在团队有新人加入时,会担心由于其不熟悉系统造成系统崩溃,而不敢放手让他真正参与进来,这样对新人的成长是十分不利的。
\轻量级应用集群
\为解决复杂的Rails系统产生的一系列问题,我们将单一系统按照业务功能进行划分,每一部分用一个独立的Rails应用来实现,从而形成若干个轻量Rails应用集群,这些应用相互协作,共同实现整体业务逻辑。
\拆分后每一个Rails应用具有如下特征:
\- 有独立的数据库,可以独立运行;\
- 程序代码量比较小,一般情况下只需要一到两个程序员开发与维护;\
- 高内聚、低耦合。\
系统进行拆分后,需要解决一系列关键的问题,例如:如何保持用户体验的一致性、应用之间如何交互、如何共享用户等。下面将逐一针对这些问题介绍解决方案。
\用户体验一致性
\系统进行拆分后,由若干个轻量级应用共同协作来完成某项业务操作。由于每一个应用都是独立的Rails程序,而一个较为复杂的业务流程可能要在多个应用间跳转,所以首先要解决用户体验的一致性问题。
\统一的css框架
\用户体验最直观的方面就是页面的样式。为了保证用户在不同的程序间跳转时没有突兀的感觉,每个应用看起来都应该“长的一样”。为达到这一目的,我们采用统一的css框架来控制样式。
\在layout里面调用Helper方法:
\\\u0026lt;%= idp_include_js_css %\u0026gt;\
这将产生以下html代码:
\\\u0026lt;script src =\"/assets/javascripts/frame.js\" type=\"text/javascript\"\u0026gt;\u0026lt;/script\u0026gt;\\u0026lt;link href=\"/assets/stylesheets/frame.css\" media=\"screen\" rel=\"stylesheet\" type=\"text/css\" /\u0026gt;\\
在frame.css中,会设定好html标签以及如导航等常用结构的样式,应用中的页面只要使用定义好的标签及css类,就可以实现统一风格的界面。
\通用客户端组件
\拆分后的Rails应用虽然处理的业务逻辑各不相同,但在用户交互上有很多相似的元素,例如查询表单、日历形式显示信息等。把产生这些元素的代码抽象成通用的方法,不仅可以保持用户体验的一致性,更可以减少代码重复,提高开发效率。
\例如要产生如下图所示的查询表单,只需要指定待查询的数据库字段以及必要的查询参数即可,具体的实现逻辑封装在search_form_for
这个Helper方法中。
\\u0026lt;%= search_form_for(Course, :id, [:price, {:range=\u0026gt;true}], :published, [:created_at, {:ampm=\u0026gt;true}]) %\u0026gt;\\
数据共享与交互
\由于每个Rails应用都是整个业务系统的一部分,除了保证用户体验的一致性外,还需要解决程序间的数据交互与共享问题。下面我们以一个简单的例子来说明如何实现。
\示例程序
\示例程序是一个简单的在线学习系统,用户在线购买和学习课程。按照业务逻辑将系统拆分成四个应用,分别用于课程信息管理(course)、用户注册登录及帐号管理(user)、订单系统(purchase)以及在线学习系统(learning)。这几个Rails应用中各自业务的实现比较简单,不再赘述。
\只读数据库
\在示例程序中,用户需要购买课程后才能开始学习。由于我们对系统进行了拆分,订单和课程在两个不同的应用中进行管理,而用户下定单时需要查看课程列表,这就涉及到一个应用(purchase)如何获取另一个应用(course)数据的问题。
\最直观的做法是course提供一个service,purchase调用这个service来取得课程列表。但service调用效率比较低,代码处理也比较复杂,所以应该尽量避免使用。我们仔细分析一下这个需求就会发现,在purchase显示的课程列表逻辑上很简单,只需要知道课程的名称、价格等基本属性就足够了,所以可以考虑直接从数据库读取这些信息。
\由于系统拆分后每个应用都有独立的数据库,所以我们需要给purchase中的Model类设定指向course的数据库连接。代码如下:
\\# purchase: /app/models/course_package.rb:\class CoursePackage \u0026lt; ActiveRecord::Base \ acts_as_readonly :course \end \\
这样CoursePackage除了数据库指向不同外,其他和普通的Model一样。
\\# purchase: /app/views/orders/new.erb.html\\u0026lt;ul\u0026gt;\\u0026lt;% CoursePackage.all.each do |package| %\u0026gt;\ \u0026lt;li\u0026gt;\u0026lt;%= package.title %\u0026gt; \u0026lt;%= package.price %\u0026gt;\u0026lt;/li\u0026gt;\\u0026lt;% end %\u0026gt;\\u0026lt;/ul\u0026gt;\\
通过acts_as_readonly
这个方法,可以让purchase的类CoursePackage从course数据库中读取数据。需要注意的是,为了保证数据维护的一致性,CoursePackage的数据库连接是只读的,这样可以避免数据在多个应用中被修改。
acts_as_readonly
的核心实现如下(限于篇幅,设置连接为只读等代码并未列出):
\def acts_as_readonly(app_name, options = {})\ config = CoreService.app(app_name).database\ establish_connection(config[Rails.env])\ set_table_name(self.connection.current_database + \".\" + table_name)\end\\
app_name是每个应用在集群中的唯一标识。purchase通过CoreService来获取course的数据库配置并设置连接。那么,CoreService向什么地方发送请求,又是如何知道course的配置信息呢?
\在应用集群中,为了降低应用间的耦合性,我们采用集中式的配置管理。选择某一个应用作为core,其他应用在server启动时将自己的配置信息发送到core集中存储。例如我们在示例程序中选择user做为core应用,purchase需要查询course的配置时,就通过CoreService向user发送请求,user根据名称查询出course包括数据库在内的所有配置信息,并返回给purchase。交互过程如下图所示:
\采用集中式的配置管理后,应用之间的调用都通过core来进行,这样就把应用之间的交互由网状结构变成以core为中心的星型结构,降低了系统配置管理的复杂度。
\应用程序的配置信息保存在config/app_config.yml中,示例如下:
\\app: course #名称,应用在系统中的唯一标识\url: example.com/course #url\api:\ course_list: package/courses\\
从上文可以看出,通过只读数据库,我们可以完全无缝地读取其他应用的数据,并且代码非常简单明了,并没有增加应用间的耦合性。
\只读数据库适应于业务逻辑比较简单的数据读取,如果数据需要预先进行复杂的操作,就无法简单地通过只读数据库取得数据。另一方面,应用间有时候确实需要进行一些写操作,这时候就需要借助于其他手段了。
\Web Service
\示例程序中,用户在purchase成功购买课程后,需要在learning这个应用中激活课程。这个过程可以通过Web Service来实现,由learning提供service接口,purchase调用这个接口并传递必要的参数。
\Rails程序一般通过ActiveResource来简化service的开发,learning中提供服务的Controller代码示例如下:
\\# learning: app/controllers/roadmap_services_Controller.rb\def create\ Roadmap.generate(params[:roadmap_service])\end\\
purchase通过RoadmapService来调用learning的service接口。
\\# purchase: app/models/roadmap_service.rb \class RoadmapService \u0026lt; ActiveResource::Base\ self.site = :learning\end\\RoadmapService.create(params)\\
我们对ActiveResource::Base
类的site=
方法进行了扩展,这样只需要指定提供service的应用名称(learning)就可以找到service的url。实现的原理仍然是通过向core发送请求,查询应用的url。
DRY
\以上介绍了如何保持用户体验的一致性以及应用间如何交互,我们可以看到这些功能的实现方法与应用的业务逻辑并不相关,属于“框架支持代码”,所以为了避免代码重复并且进一步简化开发,我们把这些方法封装到gem里面,这样每个Rails应用只需要引用这个gem,就可以无缝地集成到框架中来,并且可以使用gem里面包装好的一系列方法。
\我们已经将数据共享部分的核心代码开源,文中一些省略的代码(如acts_as_readonly
)也可以在此处找到,具体可参见http://github.com/idapted/eco_apps。
用户及权限控制
\除了数据交互外,另一个重要问题是用户的管理,包括系统登录、权限控制等方面。示例程序中,我们用user这个应用来管理用户信息。
\单点登录
\在应用集群中,用户登录某一个应用后,再访问其他应用时应该不需要再次验证,这就需要实现多个应用间的单点登录。
\实现单点登录有很多方法,我们采取一种非常简单的方式,就是多个应用共享session。代码如下:
\\# config/initializers/idp_initializer.rb \ActionController::Base.session_store = :active_record_store \ActiveRecord::SessionStore::Session.acts_as_remote :user, :readonly =\u0026gt; false\\
在initializer中指定所有应用都用user的sessions表存储session数据。当然也可以使用其他session存储方式,例如memcache等,只要保证所有应用的设置都一样即可。
\权限控制
\我们采用基于角色的权限管理来控制对应用程序的访问,并且在core应用中集中管理。应用中每一个Controller作为一个权限控制节点,在server启动时,像配置信息一样,各个应用将自己的Controller结构发送到core,由core统一管理与配置。如下图所示:
\从示例程序可以看出,将大系统拆分成小应用是基于业务来进行的,每一个应用处理一套功能上接近的、完整的业务逻辑。而每一个小应用Controller的结构,对于有多种角色的系统来说,应该按照角色来组织,这样可以有比较清晰的结构。Controller做为节点的方案也在一定程度上强迫开发者按照角色设计良好的Controller结构。
\辅助系统
\除了统一的UI、数据交互和用户共享外,还可以把一些常用的功能如上传附件、发送邮件等抽象出来,在更高级别上减少重复代码。
\由于这些功能比较复杂,不像UI Helper等用简单的一两个方法就可以完成,所以我们用独立应用和对应的gem相结合来实现。
\以发送邮件功能为例,首先创建一个Rails应用mail,主要功能包括管理邮件模版、统计发送数量、完成邮件发送等;然后创建一个gem,在这个gem中包含MailService,其他应用引用这个gem后就可以调用MailService的相关方法完成邮件发送。例如:
\\MailService.send(:welcome_mail, \"customer@example.com\