RAILS中利用YAML檔案完成資料對接

Martin91發表於2014-11-13

最近在做的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_or_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就行了。整個過程幾分鐘就完成了,簡直絲般順滑啊!

總結

最後簡單總結一下重構完的程式碼的特點吧:

  • 避免了在model或者concern中生命一堆常量或者方法,到處定義的常量會讓對映關係的管理非常分散
  • 避免不同名稱空間下的同名常量,比如Student::LOCAL_TO_REMOTE_FIELDS_MAP以及Teacher::LOCAL_TO_REMOTE_FIELDS_MAP
  • 更集中的欄位對映關係配置,避免錯漏
  • 邏輯跟對映關係解耦,更簡潔穩健的程式碼
  • 自適應新的資料表匯入,不需要再修改或者新增Ruby程式碼,配置即插即用

問題

  • 如果涉及複雜關聯,如何更好地擴充套件?
    現在的資料對接是有限制的,就是資料本身比較規則,幾乎是一張表到一張表的對接,但是如果涉及一張表到多張表之間的對接,是否可以繼續再將以上程式碼擴充套件?

  1. 說是基本資料,是因為這篇文章介紹的方案目前僅針對資料關聯不是特別複雜的場景,而且介紹的場景,資料的匯入也比較簡單,基本是從遠端資料庫中取值,然後再直接賦值到專案資料庫的記錄中。對於需要在資料匯入過程中做複雜的資料分析的案例,我暫時也沒有嘗試過,不過我預計可以嘗試使用Ruby中的程式碼塊的方式解決,但是在此不贅述。 

相關文章