Rails 记一次由 ActiveRecord 到接口数据的替换历程

dddd1919 · 2017年10月03日 · 最后由 dddd1919 回复于 2017年10月12日 · 2814 次阅读

Rails 的项目的发展历程一般是先把各种功能都怼到一个项目代码下,随着业务逐渐增长,项目会变得越来越庞大,融合了各种功能于一身,这时如果项目的某一个小功能出问题,很有可能影响整个业务,于是开始逐渐拆分解耦系统。

我们的业务过去也进行了各种拆分的尝试,开始是把非常独立的功能模块直接拆分出一套独立的代码项目,但主项目里仍然功能交错。于是乎,又开始考虑把主项目某些很通用且独立的数据拆分成微服务,其他项目使用 RPC 或 HTTP API 来读写数据,这样可以逐渐的去中心化,各个项目之间减少依赖。

起初简单的考虑使用一套服务独立存储这些数据,并提供 Restful 的 HTTP API 或 RPC 协议的接口,其他项目都通过该接口完成对数据的存取

轮子调研

因为起初没有确定是使用 HTTP API 还是 RPC 来进行数据交互,所以找了一些类似 AR 的中间层,发现还是有些质量不错的

比如 这个基于 RPC 的类 AR 数据层,使用类似 Mongoid 的字段声明方式,约定好对应的 service 来实现具体的 CURD 操作,比较简单。

另外还发现一个人气很高的基于 Restful API 的 ORM,基本实现了 AR 提供的大部分 model 方法,包括数据缓存,脏数据检查,关联关系,回调,数据校验,调用服务的接口认证等等,可以比较小的代价把 model 层从本地数据库替换成接口调用,只需要对应的接口服务提供一套 Restful 的数据接口即可。

两者相对 AR 的功能也都够用,但是还是和 AR 有着同样的问题,抽象成和连接层耦合的太紧密,如果想把 API 调用改为 RPC,或是混合使用,替换起来还是要大费周折。期望是能够把抽象成和连接层分隔开,对接方式如果更新只需要更改连接层即可,另外因为业务需要,也没法完全替换到,只是在项目中小范围尝试,还是期望以较小的改动完成一次替换。所以借鉴这些比较不错的代码,开始自己写轮子(虽然最后因为种种没有上线,过程还是非常有意思)

替换 model

替换 model 首先预期对接的是一套 Restful API 服务,提供的数据和从数据库读到的结构一致,这样以来,只需要实现一个数据的抽象层,然后对应一个连接层用来对服务数据做读取。

首先实现一个类似 Activerecord::Base 的基类,定义好抽象层的功能以及和连接层的对接",思路也是借鉴了 active_remote 的实现方式

class ActiveRemoteRecord
  include Virtus.model  # 模型字段的getter/setter

  def self.find(id)
  # 查找数据
    new proxy_service.find(id)
  end

  def self.created(params)
  # 创建数据
    new proxy_service.find(id)
  end


  def update_attributes(params)
  # 更新数据
    proxy_service.update_attributes(self.id, params)
  end

  def destroy
  # 删除数据
    proxy_service.delete(id)
  end

   def self.where(params)
  # 按条件过滤数据
    new proxy_service.search(id)
  end


  def self.proxy_class
  # 这里直接约定一个连接层service的类,model的CRUD全部调用约定类来和数据服务做交互
    "#{self.to_s}ProxyService".constantize
  end

  def self.search(params)
  # 因为model层的where方法在下面还有他用,这里声明一个search 方法和where区别一下
    proxy_service.where(params).map {|data| new(data)}
  end

  private

  def self.proxy_service
    proxy_class.instance
  end

  def proxy_service
    @proxy_service ||= self.class.proxy_service
  end

end

定义好这样一个 model 的话,如果对接一个 user 数据,只需要声明类来继承基类即可

class User < ActiveRemoteRecord
   ...
end

然后在 services 中实现对应的 proxy 类

# app/services/user_proxy_service.rb
class UserProxyService
  include Singleton

  def self.create(params)
    # request_create_user_api
  end

  def update_attributes(id, params)
    # request_find_user_api
  end

  ...
end

这样就基本完成了一个 model 到 API model 的替换,如果想要替换 API 改用 RPC 或其他形式的调用协议,修改 proxy 并保持返回值一致即可。

Assocation

完成 model 的替换后,在业务逻辑中还掺杂了很多关系方法调用,也为了保持上层逻辑的兼容,遂开始研究实现关系声明

简单来讲像 has_many/belongs_to 等声明关系的方法,在 AR 里主要就是生成特定的查询语句去获取到对应的数据,换成 API 来讲也就是使用 /users/:id/orders 这样的方法获取到 users 的所有 orders,所以思路也比较简单,就来实现一个简单的 has_many:

class ActiveRemoteRecord

  ...
    def has_many(has_many_klass, options={})
      # 动态定义关系方法
      define_method has_many_klass do
        # 获取实例缓存的关系变量
        values = instance_variable_get("@#{has_many_klass}")
        if values.blank?
          # 获取关系对象类
          class_name = options[:class_name] || has_many_klass
          klass = class_name.to_s.classify.constantize
          search_params = {}
          if options[:as]
            # 实现 polymorphic
            search_params[:"#{options[:as]}_id"] = self.id
            search_params[:"#{options[:as]}_type"] = self.class.name
          else
            foreign_key = :"#{self.class.name.demodulize.underscore}_id"
            search_params[foreign_key] = self.id
          end

           # 调用关系类的where方法
          values = klass.where(search_params)
          instance_variable_set("@#{has_many_klass}", values)
        end

        values
      end
    end
end

这样当 User 关联多个 Order 时,可以直接在 User 表中声明 has_many :orders,在 Order 声明多态的关联关系时只需加上 as 参数即可。 同理,实现 has_one, belongs_to 也都是类似的思路

链式调用

在 AR 中可以这样无数次的调用查询方法 User.where(condition_1).where(condition_2).where(...),直到最后要使用 .first .all 这样的方法时才会真正的去执行 SQL 获取数据,这也是 AR 中懒加载的套路,在一次调用后会返回一个 ActiveRecord::Relation 的实例,保存着查询的条件,再次调用实际上是再次调用这个实例的方法而不是 model 类的 where 方法,直到最后需要真正读取数据时去执行搜索。按照这个思路也来实现一版简单的链式调用:

首先来实现一个类似 ActiveRecord::Relation 的类

class RemoteRecordCondition

  def initialize(klass)
    @klass = klass
  end

  def criteria
    # 初始化的搜索条件
    @criteria ||= {:conditions => {}}
  end

  def where(args)
    # 再次调用时合并搜索条件并返回当前对象
    # 这里简单实现把多条筛选语句直接合并
    criteria[:conditions].merge!(args)
    self
  end

  def each(&block)
    # 调用该方法时才真正的搜索数据
    @klass.search(criteria[:conditions]).each(&block)
  end

  def first
    # 同上
    @klass.search(criteria[:conditions]).first
  end

  def method_missing(method_name, *arguments, &block)
    # 如果调用了数据类的类方法,中断当前的查询并直接返回类方法的结果
    if @klass.respond_to?(method_name)
      ret = @klass.send method_name
      ret = ret.where(criteria[:conditions]) if ret.instance_of?(self.class)
      return ret
    else
      super
    end
  end

end

然后把数据类的 where 方法从直接调用查询改为返回 RemoteRecordCondition 的实例:

class ActiveRemoteRecord
...
  def self.where(params)
    RemoteRecordCondition.new(self).where(params)
  end
...
end

总结

从头到尾复刻了 AR 的一些基础功能,如 attributes getter/setter, assocation, 并且可以和 AR 的 model 混用,定义数据间的简单关联关系。另外把连接层剥离开,方便替换。不过改动也比较底层最终还是没用到线上,不过过程比较有意思,也发现了很多好用的轮子

不错的方案

可以对比一下 gRPC

IChou 回复

并发要求不高的情况下还是 http API 更方便 😃

dddd1919 回复

是啊,我们当时也是经历了《gRPC 从调研到放弃》,主要还是迁移成本问题

我倒是在团队里面推行过一个和你差不多的方案:ActiveType + http API,不过只实现了一些基本的查询、验证和修改,关联和链式调用都没有做,远没有你们的方案彻底

IChou 回复

底层改动还是得慎重点,尤其这造轮子,替换生产环境可能会有预料不到的影响

需要 登录 后方可回复, 如果你还没有账号请 注册新账号