我们常用的结构化数据类型会包括JSON,YAML等等,利用Rails框架提供的便利我们能够很方便地在数据库中存取它们,这也是这篇文章要聊的事情,除此之外还会涉及应用场景,对应表字段的定义,结构化数据类型的选择以及数据的迁移脚本等方面的知识。

需求与问题

假设有这样的数据表模型

class CreateUser < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :nickname
    end
  end
end
class CreateAlipay < ActiveRecord::Migration[6.0]
  def change
    create_table :alipays do |t|
      t.references :user, null: false, foreign_key: true
      t.string :realname
      t.string :alipay
      t.string :mobile
    end
  end
end

class CreateBankcard < ActiveRecord::Migration[6.0]
  def change
    create_table :bankcards do |t|
      t.references :user, null: false, foreign_key: true
      t.string :realname
      t.string :number
      t.string :mobile
    end
  end
end

结构一目了然,就是我们有一个用户表users,然后用户可以创建自己的银行卡信息bankcards,以及支付宝信息alipays。客户方要求我们实现一个提现功能,用户可以选择用银行卡或者支付宝去提现,并生成相应的提现记录,那么用户跟提现记录的关系应该就是has_many

一开始我就犯了个错误,因为我把提现记录跟支付宝或者银行卡关联在一起了,那么提现记录表就会被设置成多态(Polymorphism)的形式,有点像是这样

class CreateWithdraw < ActiveRecord::Migration[6.0]
  def change
    create_table :withdraws do |t|
      t.references :provider, polymorphic: true, null: false
      t.string :description
    end
  end
end

那么这个时候,一个支付宝账户下就可以有多个提现记录,银行卡也类似。他们都能够在provider_id以及provider_type的配合下跟提现记录关联,用户也能借助alipays, bankcards这两个中间表与withdraws建立一对多的关系,然而系统运行一段时间后我发现我错了。

最大的问题在于万一用户自己维护的银行卡或者支付宝的数据改变,那么它们所关联提现记录里面的相关信息都会随着改变,最终会导致一堆不准确的提现记录。再者用户也不能删除银行卡或者支付宝账号,因为一旦删除,所有提现记录要么被一起删除,要么找不着对应的关联记录,还得花时间去做软删除。我想到的解决方案有下面这些

  1. 每次创建提现记录的时候都创建支付宝/银行卡记录的副本,他们跟用户自身维护的那份数据要区分开来,只用于提现记录。然而这种数据记录数量一多就难以维护。
  2. withdraws表创建银行卡所需的realname, number, mobile这些字段,以及支付宝所需要的alipay, mobile, realname这些字段,重复那些就共用就好。然而这种做法缺乏灵活性,哪天需要存储更多信息还要去修改数据表。
  3. 采用结构化数据(JSON,YAML等)来存储这些信息,入库之前先整理好相关的数据,序列化完成后入库,需要用到的时候再取出来转换成方便操作的数据结构。

我最后采用了第三种方式,以JSON的形式存取并操纵这些数据,同时获得了便利性跟灵活性。

Rails所提供的ActiveRecord::Store

Rails为ActiveRecord内置了一个叫做ActiveRecord::Store的模块,主要用于结构化数据的存取。我个人理解它的作用是对结构化数据的操作做了一层封装。为了让我们的withdraws表能够存储JSON数据我把数据表重新设计成类似这样子

class CreateWithdraw < ActiveRecord::Migration[6.0]
  def change
    create_table :withdraws do |t|
      t.text :provider_information
      t.string :description
    end
  end
end

注意我这里字段用的是text数据类型而不是json,当然如果我们的数据库支持JSON数据类型(比如Postgres/MySQL)也是可以这样去设计的

t.json :provider_information

不过这里用text其实也没差。因为序列化跟反序列化的操作ActiveRecord::Store都帮我们处理好了。我们甚至可以用JSON之外的结构化数据类型,这也是text的好处,万一哪天我们不想以JSON的方式存数据库了,简单调整代码就能换成别的结构化数据类型(当然也要做数据迁移)。如果数据表字段一开始就设置成json类型了,那么以后要存别的结构化数据就需要调整数据表了。

PS: 这也是activestorageblobs存储资源元数据(metadata)的做法。

基本用法

接下来就是要利用ActiveRecord::Store提供的方法来让结构化数据的存取更便利了。

class Withdraw < ActiveRecord::Base
  store :provider_information, accessors: [ :realname, :bankcard, :mobile, :alipay, :number ], coder: JSON
end

好吧,就那么简单

> m = Withdraw.new(description: 'first withdraw')
=> #<Withdraw id: nil, provider_information: {}, description: "first withdraw">
> m.realname = '蓝智恒'
=> "蓝智恒"
> m.alipay = '13800138000'
=> "13800138000"
> m.mobile = '13800138000'
=> "13800138000"
> m.save
=> true
 id |                               provider_information                                |  description
----+-----------------------------------------------------------------------------------+----------------
  1 | "{\"realname\":\"蓝智恒\",\"alipay\":\"13800138000\",\"mobile\":\"13800138000\"}" | first withdraw

可以看到支付宝所需要的realnamealipaymobile都被很好地以JSON的形式存储到数据库中了。

等等,明明我们数据库字段就没有设置realnamealipaymobile这些字段,为什么能够像这样去操作它们?

> m.realname = '蓝智恒'
=> "蓝智恒"
> m.alipay = '13800138000'
=> "13800138000"
> m.mobile = '13800138000'
=> "13800138000"

关键就在于

store :provider_information, accessors: [ :realname, :bankcard, :mobile, :alipay, :number ], coder: JSON

里面的accessors参数,针对相应的关键字,它能为我们生成对应的访问器,不然要这样去设置值也是挺麻烦的吧?

> m.provider_information
=> {"realname"=>"蓝智恒", "alipay"=>"13800138000", "mobile"=>"13800138000"}

> m.provider_information['extra_data'] = 'hi'
=> "hi"

> m.provider_information
=> {"realname"=>"蓝智恒", "alipay"=>"13800138000", "mobile"=>"13800138000", "extra_data"=>"hi"}

其他的结构化数据类型

除了JSON之外其实我们还可以存储其他类型的结构化数据到数据库中,当然,数据表里面的字段类型依然沿用上面的text,以大文本字符串的形式来存储。只需要简单调整一下上面的“编码器”-coder的值即可。

class Withdraw < ActiveRecord::Base
  store :provider_information, accessors: [ :realname, :bankcard, :mobile, :alipay, :number ], coder: YAML
end

现在再往里面存储数据看看

> yaml_store = Withdraw.new(description: 'yaml withdraw')
=> #<Withdraw id: nil, provider_information: {}, description: "yaml withdraw">
> yaml_store.realname = '蓝智恒'
=> "蓝智恒"
> yaml_store.mobile = '13800138000'
=> "13800138000"
> yaml_store.alipay = '13800138000'
=> "13800138000"
> yaml_store.save
=> true

> yaml_store
=> #<Withdraw id: 1, provider_information: {"realname"=>"蓝智恒", "mobile"=>"13800138000", "alipay"=>"13800138000"}, description: "yaml withdraw">

在ruby的控制台里面它依旧是哈希的形式,然而在数据库里面它会变成

> SELECT * FROM withdraws;
 id |                  provider_information                   |  description
----+---------------------------------------------------------+---------------
  1 | --- !ruby/hash:ActiveSupport::HashWithIndifferentAccess+| yaml withdraw
    | realname: 蓝智恒                                       +|
    | mobile: '13800138000'                                  +|
    | alipay: '13800138000'                                  +|
    |                                                         |

也就是入库之前它会把Ruby原生的哈希数据转换成YAML格式再存储进去。目前用得比较多的可能就是JSON跟YAML了吧,如果需要其他的序列化类型估计要自己写扩展了。不过一般来说我们也不会在意它们在数据库里面是如何存储的,只要能够提取出来并以哈希的方式去操作就好了。

使用结构化数据类型的字段

文档是这样说的

NOTE: If you are using structured database data types (e.g. PostgreSQL hstore/json, or MySQL 5.7+ json) there is no need for the serialization provided by .store. Simply use .store_accessor instead to generate the accessor methods. Be aware that these columns use a string keyed hash and do not allow access using a symbol.

文档也说了如果我们数据库的字段用的是json而不是text,那么我们调用的类方法可以是store_accessor

在这种情况下数据表应该这样去构造

class CreateWithdraw < ActiveRecord::Migration[6.0]
  def change
    create_table :withdraws do |t|
      t.json :provider_information
      t.string :description
    end
  end
end

这个时候就不需要设置编码器了,反正最终会以JSON的形式存储,直接设置我们想要的访问器就行

class Withdraw < ActiveRecord::Base
  store_accessor :provider_information, [:realname, :bankcard, :mobile, :alipay, :number]
end

只是现在数据库中的对应字段不再是以字符串的形式去存储,而是数据库内置的JSON或者说哈希数据类型。存储效果也是类似的


> SELECT provider_information FROM withdraws;

 id |   provider_information    |  description
----+---------------------------+---------------
  1 | {"realname":"json store"} | json withdraw
(1 row)

在这种情况下,对应字段的数据类型是哈希,所以可以直接使用PG的->>操作符来单独查询对应字段,而不需要事先进行类型转换

> SELECT provider_information ->> 'realname' AS realname, description FROM withdraws;
  realname  |  description
------------+---------------
 json store | json withdraw
(1 row)

前面也说过,如果可以的话字段还是以text数据类型去配置会好一些,万一哪天需要从JSON格式转存成YAML格式也不需要调整数据表了,有更好的兼容性跟可扩展性。哪怕是以文本的方式来存储JSON数据,做基于结构化数据中字段的查询时也能够采用先做强制转换再查询的方式(YAML就没办法了),例如

> SELECT * FROM withdraws WHERE provider_information::json ->> 'realname' ~ '智恒';
 id |             provider_information             |  description
----+----------------------------------------------+---------------
  3 | {"realname":"蓝智恒","mobile":"13800138000"} | json text withdraw
(1 row)

> SELECT * FROM withdraws WHERE provider_information::json ->> 'realname' ~ 'hack';
 id | provider_information | description
----+----------------------+-------------
(0 rows)

数据迁移

随着业务的推进,我们经常需要调整(或者颠覆)原来所设计的数据表,如果是一个刚开始的项目倒是没什么,表不满意就直接推翻重来就好了,然而如果是已经运行一段时间的项目,数据表里面必然包含一些历史数据,而调整数据表的过程中一不小心就会毁坏这些数据。还有就是采用了新的数据库字段之后可能需要把一些原有字段中的数据迁移过来,原来的字段就可以删掉了。这一小节的重点就是后者,如何把数据库中已有字段中的数据迁移到新的结构化数据字段中去?

笔者比较习惯在migration的过程完成数据迁移,而不是部署完成之后再用Rake Task或者操作数据库的方式来迁移数据。假设最开始alipayswithdraws的表关系是这样

class CreateAlipay < ActiveRecord::Migration[6.0]
  def change
    create_table :alipays do |t|
      t.references :user, null: false, foreign_key: true
      t.string :realname
      t.string :alipay
      t.string :mobile
    end
  end
end
class CreateWithdraw < ActiveRecord::Migration[6.0]
  def change
    create_table :withdraws do |t|
      t.references :alipay, null: false, foreign_key: true
      t.string :description
    end
  end
end

简单起见model里面的一对多关系我就不写了,假设我有两个支付宝账号哈,并有一堆的提现记录

> SELECT withdraws.id, withdraws.description, alipays.id AS alipay_id, realname, alipays.alipay, mobile FROM withdraws, alipays where alipays.id = withdraws.alipay_id;
 id | description | alipay_id | realname |   alipay    |   mobile
----+-------------+-----------+----------+-------------+-------------
  1 | 提现记录    |         3 | 蓝智恒   | 13800138000 | 13800138000
  2 | 提现记录2   |         3 | 蓝智恒   | 13800138000 | 13800138000
  3 | 提现记录3   |         4 | 蓝智恒   | 13800138001 | 13800138001
  4 | 提现记录4   |         4 | 蓝智恒   | 13800138001 | 13800138001
(4 rows)

之前也说过这种方法有个致命问题,万一哪天我支付宝账号改了的话,会影响所有提现记录,于是简单起见这里我们用结构化数据类型来存储支付宝账号的相关信息。银行卡也是类似这里就不说了,并且同一条数据里面两者不会冲突(因为你只会选择一种提现方式)。需要做以下几件事情

  1. 建立相关的结构化数据存储字段。
  2. 建立提现记录与用户的直接关联,为后期删除支付宝的关联做准备(避免提现记录成为游离数据)。
  3. 数据迁移。
  4. 删除支付宝与提现记录的一对多关系。

改表和数据迁移可以一起做

class AddFileForSavingJson < ActiveRecord::Migration[6.0]
  def up
    add_reference :withdraws, :user, foreign_key: true
    add_column :withdraws, :provider_information, :text

    execute <<-SQL
            UPDATE withdraws
            SET user_id = alipays.user_id
            FROM alipays
            WHERE withdraws.alipay_id = alipays.id
    SQL

    execute <<-SQL
            UPDATE withdraws
            SET provider_information = json_build_object('realname', a.realname, 'mobile', a.mobile, 'alipay', a.alipay)
            FROM alipays AS a
            WHERE withdraws.alipay_id = a.id
    SQL
  end

  def down
    remove_column :withdraws, :provider_information, :text
    remove_reference :withdraws, :user, foreign_key: true
  end
end

up方法除了创建用于存储提现方式的provider_information以及user_id之外还执行了两条SQL语句。第一条主要查询出alipays表里面每条记录所对应的user_id,然后把这个id设置到withdrawsuser_id字段中。这样即便哪天删除了提现记录跟alipays表的关联也不会变成游离数据了。down方法要做的事情其实就是把新增的字段删掉。

跑完migration之后再查看一下withdraws表的数据

> SELECT * FROM withdraws;

id | alipay_id | description | user_id |                            provider_information
----+-----------+-------------+---------+-----------------------------------------------------------------------------
  1 |         3 | 提现记录    |       1 | {"realname" : "蓝智恒", "mobile" : "13800138000", "alipay" : "13800138000"}
  2 |         3 | 提现记录2   |       1 | {"realname" : "蓝智恒", "mobile" : "13800138000", "alipay" : "13800138000"}
  3 |         4 | 提现记录3   |       1 | {"realname" : "蓝智恒", "mobile" : "13800138001", "alipay" : "13800138001"}
  4 |         4 | 提现记录4   |       1 | {"realname" : "蓝智恒", "mobile" : "13800138001", "alipay" : "13800138001"}

很好,alipay_id对应的数据全都迁移过来了。在线上运行一段时间没有问题之后就可以构建下面这个脚本

class RemoveAlipayIdFromWithdraw < ActiveRecord::Migration[6.0]
  def change
    remove_reference :withdraws, :alipay, foreign_key: true
  end
end

alipay_idwithdraws表中删掉了,因为新的应用逻辑会在创建提现记录的时候把提现方式的信息以结构化数据的形式存储到provider_information中去,不再需要alipays来做中间表了。不过笔强烈建议在线上运行一段时间之后再把这层关联给去掉,不然字段一旦被删除哪怕表结构能够复原,里面的数据也无法挽回了(笔者作为过来人奉劝,别对自己的代码太自信了)。

尾声

在Rails里面对结构化数据的存储就介绍到这里,它不仅简单而且灵活,特别针对那些变数较大的业务场景(经常要调整字段)。有时候一个灵活的数据表设计能够为我们省下几十行复杂的业务代码,大家可以根据自身的业务来决定是否要使用。笔者能力有限,若有不当之处还望指正。