RAILS中利用YAML文件完成数据对接

November 2014 · 3 minute read

最近在做的Ruby on Rails项目中,需要将远程数据库中的数据对接到项目数据库中,但是远程的数据不仅数据表名跟字段命名奇葩,数据结构本身跟项目数据结构出入比较大,在数据导入过程中代码经历了几次重构,最后使用了YAML文件解决了基本数据1对接的问题。在此写一篇博文,我会尽量重现一路过来的代码变更,算是分享一下我的思考过程,也算是祭奠一下自己的苦逼岁月。

假设以及数据结构预览

因为远程数据库服务器为Oracle Server,我在项目中使用到了Sequel这个gem用于连接数据库以及数据查询,因为数据库连接的内容不是本文的重点,故后续代码直接用remote_database表示数据库连接,而根据Sequel的用法,我们可以直接使用remote_database[table_name]连接到具体的表。

本次需要从远程数据库中导入的基本数据主要有学生信息表(包含班级名称)、老师信息表以及专业信息表,相应地,项目中(以下称为“本地”)也已经创建好了对应的model。其中学生信息表的表名以及部分数据字段的从本地到远程的映射关系如表所示:

表名或字段名 本地 远程
表名 students XSJBXX
姓名 name XM
学号 number XH
年级 grade NJ
班级 belongs_to :klass     BJMC(班级名称)

老师信息表的表名以及部分数据字段的映射关系为:

表名或字段名 本地 远程
表名 teachers JZGJBXX
姓名 name XM
职称 title ZC
证件号码 id_number ZJHM

数据对接第一版:属性方法显式赋值

第一个导入的数据表是学生的信息表,在最开始的时候,因为只需要考虑一张单独的表,所以代码写得简单粗暴,基本过程就是:根据需要的信息,查询对应的远程数据字段,然后使用属性方法赋值,最后保存接入的数据。对接方法的部分相关代码示例(为了方便阅读以及保护项目敏感信息,本文对项目中原有代码进行了缩减以及修改):

# app/models/student.rb
class Student < ActiveRecord::Base
  def import_data_from_remote
    remote_students = remote_database[:xsjbxx].page(page)

    remote_students.each do |remote_student|
      name, number, grade = *remote_student.values_at(:xm, :xh, :nj)
      class_name = remote_student[:bjmc]

      klass = Klass.find_or_create_by name: class_name
      student = Student.find_by_create_by name: name,
                                          number: number,
                                          grade: grade,
                                          klass: klass
    end
  end
end

上面的代码,呃,中规中矩,基本体现了各取所需的指导思想,但是总觉得怎么有点不好呢?

数据对接第二版:通过本地到远程数据库字段映射关系自动匹配赋值

在第一版的代码中,最大的坏味道在于:代码中需要把所有需要对接的字段列举出来,一旦遇到字段增删修改的情况,就需要同时更新原来的逻辑代码,太不灵活了,而且列举所有字段本身就是一件非常繁琐枯燥的事情。再假设字段很多的情况下,要从代码中一个个检查字段的名称,肯定是件多么可怕的事情啊。

那么怎么修改呢?用映射表!仔细观察第一段的代码,其实代码所做的工作如此简单:无非是先从远程数据中取值,然后赋值到本地数据对象的对应属性中,这种“本地-远程”的字段映射关系,不就是我们每天面对的“键-值”对的特征吗?那直接用一个Hash来保存这种对应关系不就好了。

话不多说,我们开始重构:

# app/models/student.rb
class Student < ActiveRecord::Base
  LOCAL_TO_REMOTE_FIELDS_MAP = {
    number: :xh,
    name: :xm,
    age: :nj
  }

  LOCAL_TO_REMOTE_ASSOCIATION_MAP = {
    klass: {
      association_field_name: :name,
      remote_field_name: :bjmc
    }
  }

  def import_data_from_remote
    remote_students = remote_database[:xsjbxx].page(page)

    remote_students.each do |remote_student|
      student = Student.find_or_initialize_by xxx: xxx
      LOCAL_TO_REMOTE_FIELDS_MAP.keys.each do |attribute|
        # 逐一调用属性赋值方法,完成Student属性的赋值
        student.send("#{attribute}=", remote_student[LOCAL_TO_REMOTE_FIELDS_MAP[attribute]])
      end

      LOCAL_TO_REMOTE_ASSOCIATION_MAP.each do |association_name, association_fields_map|
        # 把远程数据赋给对应的本地数据字段
        association_field_name = association_fields_map[:association_field_name]
        remote_value = remote_student[association_fields_map[:remote_field_name]]

        # 查找或创建关联对象
        related_object =
          reflect_on_association(association_name).klass.find_or_create_by association_field_name => remote_value
        # 建立关联关系
        local_object.send("#{association_name}=", related_object)
      end

      student.save
    end
  end
end

在上面的示例中,我们用常量LOCAL_TO_REMOTE_FIELDS_MAP保存Student这个model本身的字段跟远程数据字段的映射关系,这样我们就可以通过类似LOCAL_TO_REMOTE_FIELDS_MAP[:number]知道学生的姓名在远程数据表中对应的字段是:xm了。另外值得一提的是,我用了LOCAL_TO_REMOTE_ASSOCIATION_MAP这个常量保存了学生与班级关联关系,同时保存了关联的klass的数据字段映射关系。

在声明了必要的字段映射关系之后,我就在代码中遍历了每一个字段,并且通过对应的远程字段名称查找对应的数值,并且使用send方法调用了对象的属性赋值方法,将数据自动对接到本地数据对象上。

到目前为止,代码行数虽然反而多了,但是却实现了字段映射关系与逻辑代码的分离,我们可以独立管理映射关系了。以后就算需要加入新的对接字段,只要在LOCAL_TO_REMOTE_FIELDS_MAP中添加新的键值对就好了,甚至可以在LOCAL_TO_REMOTE_ASSOCIATION_MAP添加类似klass的简单关联关系的数据接入,而这些都无需修改逻辑代码。

数据对接第三版:教职工信息也需要导入了,代码拷贝之旅开始了

毫无疑问,如果只是满足于学生信息的对接,相信上面的代码也都够用了,代码的重构也可以告一段落了。

但是,前面说了,除了学生的信息,还有教职工的信息需要做接入,而且从最开始的假设以及数据结构预览一节看到,老师的数据结构跟学生的数据结构极其相似,所以,时间紧迫,我就直接拷贝代码然后简单删改了一下:

# app/models/teacher.rb
class Teacher < ActiveRecord::Base
  LOCAL_TO_REMOTE_FIELDS_MAP = {
    number: :xh,
    title: :zc,
    id_number: :zjhm
  }

  def import_data_from_remote
    remote_teachers = remote_database[:jzgjbxx].page(page)

    remote_teachers.each do |remote_teacher|
      teacher = Teacher.find_or_initialize_by xxx: xxx
      LOCAL_TO_REMOTE_FIELDS_MAP.keys.each do |attribute|
        teacher.send("#{attribute}=", remote_teacher[LOCAL_TO_REMOTE_FIELDS_MAP[attribute]])
      end

      teacher.save
    end
  end
end

注意在上面的代码中,Teacher中比起Student,少了LOCAL_TO_REMOTE_ASSOCIATION_MAP常量,并且也删除了相关的代码,虽然代码已经满足需求了,教职工的数据导入也是无比顺利,可是面对着一堆重复的代码,真心别扭!

数据对接第四版:抽象逻辑,代码共享

其实我多少也是有代码洁癖的,大片Copy的代码岂不是搞得自己逼格好Low?怎么可以忍受,继续重构!

这一次重构其实就简单多了,把重复的核心逻辑代码抽取出来,然后放到一个专门负责数据对接的Concern里边,最后在需要此concern的model里include一下就行了。话不多说,上Concern代码:

# app/models/concerns/import_data_concern.rb
module ImportDataConcern
  extend ActiveSupport::Concern

  module ClassMethods
    def import_data_from_remote
      remote_objects = remote_database[self::REMOTE_TABLE_NAME].page(page)

      remote_objects.each do |remote_object|
        object = self.find_or_initialize_by xxx: xxx
        self::LOCAL_TO_REMOTE_FIELDS_MAP.keys.each do |attribute|
          # 逐一调用属性赋值方法,完成Student属性的赋值
          object.send("#{attribute}=", remote_object[self::LOCAL_TO_REMOTE_FIELDS_MAP[attribute]])
        end

        if self::LOCAL_TO_REMOTE_ASSOCIATION_MAP
          self::LOCAL_TO_REMOTE_ASSOCIATION_MAP.each do |association_name, association_fields_map|
            # 把远程数据赋给对应的本地数据字段
            association_field_name = association_fields_map[:association_field_name]
            remote_value = remote_object[association_fields_map[:remote_field_name]]

            # 查找或创建关联对象
            related_object =
              reflect_on_association(association_name).klass.find_or_create_by association_field_name => remote_value
            # 建立关联关系
            local_object.send("#{association_name}=", related_object)
          end
        end

        object.save
      end
    end
  end
end

在上面的代码中,我们把核心对接逻辑抽了出来,并且抽象了远程数据表名的配置,另外通过if self::LOCAL_TO_REMOTE_ASSOCIATION_MAP兼容关联关系的导入。 为了在Teacher以及Student中正常运行上面的代码,我们还需要在这两个model分别include当前的concern,并且声明必要的常量:

# app/models/student.rb
class Student < ActiveRecord::Base
  include ImportDataConcern

  REMOTE_TABLE_NAME = 'XSJBXX'
  LOCAL_TO_REMOTE_FIELDS_MAP = {
    number: :xh,
    name: :xm,
    age: :nj
  }

  LOCAL_TO_REMOTE_ASSOCIATION_MAP = {
    klass: {
      association_field_name: :name,
      remote_field_name: :bjmc
    }
  }
end
# app/models/teacher.rb
class Teacher < ActiveRecord::Base
  include ImportDataConcern

  LOCAL_TO_REMOTE_FIELDS_MAP = {
    number: :xh,
    title: :zc,
    id_number: :zjhm
  }
end

经过上面的重构,原本重复的代码已经变成了一个Concern,通过Concern来管理独立的业务逻辑,也使得代码管理起来更方便了。但是,等等,我们的重构之旅还在继续!

数据对接第五版:砍掉恶心的常量,使用YAML配置映射关系

当时在写代码的过程中,我就一直感觉一大堆的常量令人无法直视,但是,如果不用常量,我还能怎么做?尽管前面两个表的数据导入任务完成了,我还是纠结于代码中那恶心死了的常量(实际上,我当时写的常量比你们现在看到的更多,文章中的只不过是示例)。而庆幸的是,那天脑洞一开:“这些映射关系本质上不就是一堆配置信息吗?而我在代码中的常量也就是用Hash存储的,那用YAML文件不就刚好了吗?”。是啊,像config/database.yml这类的文件,一直以来都是用于保存配置信息的啊,一个是符合Rails的使用习惯,另一个也确实符合数据结构的要求。Awesome,这就开始动工。

首先第一件事,我就把那些常量搬到了yaml文件中,并且放在了项目的config/目录下:

default:
  remote_unique_field_name: number

models:
  student:
    remote_table_name: xsjbxx
    local_to_remote_fields_map:
      number: xh
      name: xm
      grade: nj
    local_to_remote_association_map:
      klass:
        association_field_name: name
        remote_field_name: bjmc

  teacher:
    remote_table_name: jzgjbxx
    local_to_remote_fields_map:
      name: xm
      title: zc
      id_number: zjhm

配置好了yaml,那么又要如何方便地读取配置信息呢?我的方法是在config/iniitializers/目录下新建了一个initializer,主要用于在项目启动时加载配置信息,关键代码段:

module RemoteDatabase
  def self.fields_map
    return @fields_map if @fields_map

    @fields_map ||=
      YAML::load_file(Rails.root.join('config', 'local_to_remote_oracle_database_map.yml'))
  end
end

所以,以后只要使用RemoteDatabase.fields_map就能读取到所有数据字段映射关系了!

万事俱备之后,我最后需要做的事情就是把Concern中的常量替换为从YAML中读取到的配置就好了,重构后的代码为:

module ImportDataConcern
  extend ActiveSupport::Concern

  module ClassMethods
    def importing_fields_map
      return @fields_map if @fields_map

      @fields_map =
        RemoteDatabase.fields_map[:default].merge(
          RemoteDatabase.fields_map[:models][self.name.underscore]
        )
    end

    def import_data_from_remote
      remote_objects = remote_database[importing_fields_map[:remote_table_name]].page(page)

      remote_objects.each do |remote_object|
        # 通过值唯一的属性查找对象
        remote_unique_field_name = importing_fields_map[:remote_unique_field_name]
        remote_unique_field = remote_object[importing_fields_map[:local_to_remote_fields_map][remote_unique_field_name]]
        local_object = find_or_initialize_by(remote_unique_field_name => remote_unique_field)

        local_to_remote_fields_map = importing_fields_map[:local_to_remote_fields_map]
        # 逐一设置本地对象需要对接的各个属性
        local_to_remote_fields_map.keys.each do |attribute|
          local_object.send("#{attribute}=", remote_object[importing_fields_map[:local_to_remote_fields_map][attribute]])
        end

        # ... 关联关系的保存

        next unless local_object.changes.any?

        local_object.save
      end
    end
  end
end

上面代码中,importing_fields_map读取与当前Model匹配的字段映射关系,其内部先通过RemoteDatabase.fields_map[:default]加载了默认的配置,然后通过mergeRemoteDatabase.fields_map[:models][self.name.underscore]得到当前model专属的配置,其中的self.name.underscore的值类似于'student'或者'teacher'

在后续的代码中,基本跟前面列举的代码一致,只是将各种常量对应替换为通过local_to_remote_fields_map存储的配置,并且删除Student以及Teacher的多余常量,在此就不列举示例代码了。

在整个重构的过程中,代码是越来越抽象的,但是代码本身却也因此变得越来越灵活,而至此,我们已经完全将字段映射关系从Ruby代码中剥离,假使以后还需要导入其他数据,我们只需要修改YAML文件,而不再需要碰任何Ruby代码,除非我们需要修改配置项的结构。

收获重构后的果实:专业数据的导入

在经历过了几次重构后,今天开始导入学生专业的数据,而我所需要做的全部事情,仅仅只是在yaml文件中加入专业相关的配置,并且在专业的modelMajorinclude一下数据导入的Concern就行了。整个过程几分钟就完成了,简直丝般顺滑啊!

总结

最后简单总结一下重构完的代码的特点吧:

问题


  1. 说是基本数据,是因为这篇文章介绍的方案目前仅针对数据关联不是特别复杂的场景,而且介绍的场景,数据的导入也比较简单,基本是从远程数据库中取值,然后再直接赋值到项目数据库的记录中。对于需要在数据导入过程中做复杂的数据分析的案例,我暂时也没有尝试过,不过我预计可以尝试使用Ruby中的代码块的方式解决,但是在此不赘述。 [return]