zoukankan      html  css  js  c++  java
  • Rails系统重构:从单一复杂系统到多个小应用集群

    InfoQ: Rails系统重构:从单一复杂系统到多个小应用集群

    小应用集群架构的缺陷

    Rails系统重构:从单一复杂系统到多个小应用集群

    作者

    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为用户打造最好的在线学习平台。

  • 相关阅读:
    Ubuntu创建VLAN接口配置
    Ubuntu配置网桥方法
    Ubuntu重启网卡的三种方法
    kvm存储池和存储卷
    ubuntu如何切换到root用户
    修改Ubuntu默认apt下载源
    Java之泛型<T> T与T的用法(转载)
    Java关键字(一)——instanceof(转载)-BeanCopier相关
    java代码之美(12)---CollectionUtils工具类(转载)
    Lombok-注解@Slf4j的使用
  • 原文地址:https://www.cnblogs.com/lexus/p/2394491.html
Copyright © 2011-2022 走看看