有时候一个灵活的数据表设计能够为我们省下几十行复杂的业务代码,这篇文章简单来聊聊在 Rails 中结构化数据类型的存取,不当之处还望指正。原文链接:https://www.lanzhiheng.com/posts/structured-data-type-store-in-rails
我们常用的结构化数据类型会包括 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
建立一对多的关系,然而系统运行一段时间后我发现我错了。
最大的问题在于万一用户自己维护的银行卡或者支付宝的数据改变,那么它们所关联提现记录里面的相关信息都会随着改变,最终会导致一堆不准确的提现记录。再者用户也不能删除银行卡或者支付宝账号,因为一旦删除,所有提现记录要么被一起删除,要么找不着对应的关联记录,还得花时间去做软删除。我想到的解决方案有下面这些
withdraws
表创建银行卡所需的realname
, number
, mobile
这些字段,以及支付宝所需要的alipay
, mobile
, realname
这些字段,重复那些就共用就好。然而这种做法缺乏灵活性,哪天需要存储更多信息还要去修改数据表。我最后采用了第三种方式,以 JSON 的形式存取并操纵这些数据,同时获得了便利性跟灵活性。
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: 这也是active_storage_blobs存储资源元数据 (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
可以看到支付宝所需要的realname
, alipay
, mobile
都被很好地以 JSON 的形式存储到数据库中了。
等等,明明我们数据库字段就没有设置realname
, alipay
, mobile
这些字段,为什么能够像这样去操作它们?
> 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 或者操作数据库的方式来迁移数据。假设最开始alipays
跟withdraws
的表关系是这样
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)
之前也说过这种方法有个致命问题,万一哪天我支付宝账号改了的话,会影响所有提现记录,于是简单起见这里我们用结构化数据类型来存储支付宝账号的相关信息。银行卡也是类似这里就不说了,并且同一条数据里面两者不会冲突(因为你只会选择一种提现方式)。需要做以下几件事情
改表和数据迁移可以一起做
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 设置到withdraws
的user_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_id
从withdraws
表中删掉了,因为新的应用逻辑会在创建提现记录的时候把提现方式的信息以结构化数据的形式存储到provider_information
中去,不再需要alipays
来做中间表了。不过笔强烈建议在线上运行一段时间之后再把这层关联给去掉,不然字段一旦被删除哪怕表结构能够复原,里面的数据也无法挽回了(笔者作为过来人奉劝,别对自己的代码太自信了)。
在 Rails 里面对结构化数据的存储就介绍到这里,它不仅简单而且灵活,特别针对那些变数较大的业务场景(经常要调整字段)。有时候一个灵活的数据表设计能够为我们省下几十行复杂的业务代码,大家可以根据自身的业务来决定是否要使用。笔者能力有限,若有不当之处还望指正。