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 方法:
<%= idp_include_js_css %>
这将产生以下 html 代码:
<script src ="/assets/javascripts/frame.js" type="text/javascript"></script> <link href="/assets/stylesheets/frame.css" media="screen" rel="stylesheet" type="text/css" />
在 frame.css 中,会设定好 html 标签以及如导航等常用结构的样式,应用中的页面只要使用定义好的标签及 css 类,就可以实现统一风格的界面。
通用客户端组件
拆分后的 Rails 应用虽然处理的业务逻辑各不相同,但在用户交互上有很多相似的元素,例如查询表单、日历形式显示信息等。把产生这些元素的代码抽象成通用的方法,不仅可以保持用户体验的一致性,更可以减少代码重复,提高开发效率。
例如要产生如下图所示的查询表单,只需要指定待查询的数据库字段以及必要的查询参数即可,具体的实现逻辑封装在search_form_for
这个 Helper 方法中。
<%= search_form_for(Course, :id, [:price, {:range=>true}], :published, [:created_at, {:ampm=>true}]) %>
数据共享与交互
由于每个 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 < ActiveRecord::Base acts_as_readonly :course end
这样 CoursePackage 除了数据库指向不同外,其他和普通的 Model 一样。
# purchase: /app/views/orders/new.erb.html <ul> <% CoursePackage.all.each do |package| %> <li><%= package.title %> <%= package.price %></li> <% end %> </ul>
通过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 < 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 => 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", :username=>" 张三 ")
架构优势及系统拆分原则
小应用集群架构的优势
我们在第一部分已经详细说明了复杂 Rails 系统的种种弊端,将大系统拆分成小应用集群后,可以从根本上解决这些问题,并且还可以带来许多其他好处。
快速需求响应
由于每个应用只关注于大系统中的一部分业务逻辑,所以应用代码量一般都比较小。这种小规模的应用是非常容易维护与扩展的。
首先小应用代码比较清晰,无论是 BUG 修复还是功能扩展或者代码重构都很容易进行。其次测试和部署的周期更短,并且由于各个应用间彼此独立,某个应用的崩溃不会影响到其他应用的正常运行。更快的开发部署周期就保证了系统对于用户需求的快速响应,有利于提高产品的竞争力。
新技术的安全迭代
Ruby/Rails 技术社区十分活跃,不仅 Rails 的版本升级速度很快,各种 gem、插件、Web Server 等也层出不穷。为了不断提高系统的性能、架构与可扩展性,我们经常需要对 Rails 进行升级或者引进一些新技术。但对于复杂的 Rails 系统,升级 Rails 是一件十分痛苦的事情,由于代码太复杂或没有完善的测试代码,升级后往往会破坏系统的健壮性。另外,引入的新技术可能存在缺陷,运行一段时期发现问题后又需要退回初始版本,这个反复的过程严重降低了生产效率。
而将系统进行拆分后,在升级 Rails 或应用新技术时,可以拿某一个不是特别重要的应用做实验。通过实验总结出一些升级经验,或验证新技术是否可用,没有问题后再推广到其他应用,这样就可以逐步地、安全地完成新技术的更新。
团队建设
对于复杂的 Rails 系统,团队有新成员加入时,会由于经验、技术水平不足等无法接触系统,往往需经过较长时间的培训才可以真正进入开发。另外由于系统的复杂度及新手对系统整体缺乏了解,很容易在修改代码后影响系统其他部分。而对于小应用集群,可以让新手从较简单、非核心的系统着手,对技术、业务较为熟悉后再转移到其他应用,这样就可以迅速、平滑地融入团队。
由于轻量级应用维护、扩展都比较简单,一般一个开发者可以负责多个应用,这样开发者就有了很大的自主性。另外如果有错误也不会影响他人,降低了风险,更有利于团队合作。在这样简单、安全、快速迭代的开发环境中,开发者的工作积极性会更高。
小应用集群架构的缺陷
由于多个应用之间的交互很大一部分是通过只读数据库来进行的,而数据库难以做到远程连接,所以要求所有的应用都部署在同一个局域网上。对于要求有大量服务器并且分布于多个数据中心(甚至多个国家)的情况,就不能完全依赖于该架构实现。当然广域网上的分布式应用需要考虑更多的问题,超出了本文的讨论范围,这里不再赘述。
另外由于每个应用都依赖于做为基础库的 gem,所以 gem 里面的方法更改后需要重新启动所有相关的应用,方法才能生效。gem 方法一旦出错,会牵连到多个应用,所以对 gem 方法的更改一定要慎重,并且要经过充分的测试确保没有问题后再进行部署。为了降低风险,最好有一个 gem 方法的引用列表,这样可以确保 gem 方法升级后不会带来潜在风险。
系统划分原则
系统划分是否合理直接关系到代码和架构的质量。一般来说,拆分系统要遵守以下几个原则:
- 每个应用独立完成一个完整的业务逻辑。例如在示例程序中,course 这个应用应该完成所有的课程管理操作,包括课程的基本信息维护、课程包的管理等。如果把课程的基本信息维护和课程包维护这两个联系十分紧密的业务功能放在两个应用中,就会造成这两个应用间频繁的交互,导致系统性能和代码质量的下降。
- 按照业务而不是按照用户角色来拆分系统。
- 高内聚。核心业务处理不应该通过 service 来处理,一个应用中最关键的业务逻辑应该不依赖于其他应用,完全可以自我实现。
- 低耦合。一个应用不应该知道太多其他应用的业务逻辑,程序之间的接口越小越好。
系统拆分的好坏并没有可以量化的标准,很多时候需要经过多次迭代才能达到比较合理的划分。在开始的时候可以大胆一些,将系统拆分成尽可能多的应用,然后在开发过程中如果发现应用间交互很频繁,则可以将其合并为一个应用。
小结
以上介绍了如何把一个复杂的 Rails 系统拆分成多个轻量级应用集群,这个框架结构在 idapted 一年多的实践来看效果还是很令人满意的。以 idapted 的在线英语学习平台 eqenglish 为例,现在大约有 15 个业务系统,4 个支撑应用,核心应用每个约有 40 个左右的 Model。每周有 1-2 次重大更新部署,每天有 30-40 次代码签入和 3-5 次 BUG 修复或功能改进更新。由于框架提供了很多基础设施,搭建一个新应用的时间也大大缩短,例如用于跟踪学生学习状态的 LPR(Learner Progress Report)一个人两周即开发完成并上线。
当然框架还有很多需要完善的地方,例如应用间的写交互通过消息队列来实现、如何做集成测试等,希望能得到 Ruby/Rails 社区更多的反馈与意见。
关于作者
郭磊, idapted 首席架构师,多年 Java/.net 开发与架构经验,2007 年以来专注于用 Ruby/Rails 为用户打造最好的在线学习平台。
注:本文是 idapted 公司 Rails 系列技术文章的第一篇,为 RailsConf 2010 的演讲《 From 1 to 30: How to Refactor 1 Monolithic Application into 30 Independently Maintainable Applications 》的整理和总结。
评论