今天想对Rails里面自带的附件管理工具ActiveStorage做个简单的剖析。如果一篇文章讲不完那就做成一个系列吧,这篇就专注讲讲ActiveStorage数据存储相关的问题。

前言

在许多业务场景下,附件的管理都是一个比较麻烦的问题。在Rails里面一般的数据库字段(数字,字符串等等),我们都会通过ORM来设置好对应的值,然后通过save方法来直接数据入库就是了。然而如果资源本身需要关联一些附件资源(图片,视频,文档等),事情就没那么简单了。

虽说现在像PostgreSQL这样的数据库可以存储二进制数据,不过我想,一般不会真的塞一张图片进去吧?况且他二进制的存储空间有限,用来存放一些不太长的二进制串还比较凑合

1 or 4 bytes plus the actual binary string

像图片,视频这些附件资源,我们一般会把它们放在服务器的相关目录下,又或者托管到第三方的OSS服务中。然后资源的数据表则只需要申请一个字段来存储附件的存储路径即可。考虑一下下面这个数据表:

CREATE TABLE public.users (
    id bigint NOT NULL,
    name character varying,
    avatar character varying,
    created_at timestamp(6) without time zone NOT NULL,
    updated_at timestamp(6) without time zone NOT NULL
);

这里面的avatar字段可以用来存储文件在当前服务器上的路径如./public/images/xxxxx.png,又或者是第三方OSS服务所提供的路径https://third-part/images/xxxx.png。结果有点像是这样

                   name                    |              avatar
-------------------------------------------+----------------------------------
 杭州软件测试圈                            | avatar/21165/9b58d6.jpeg
 DOTA                                      | avatar/28415/27481e.jpg

个人更倾向于把附件存放在第三方服务中,这种方式虽然要多花点钱,不过迁移成本比较低,速度快,配合第三方的容灾措施,可靠性也比自己去维护要高一些。当然如果不喜欢第三方服务的付费模式(或者鉴黄机制),倒是可以自己去维护这些附件资源,定时备份一下就好。

异于常人

active-storage-cover.png

ActiveStorage采取了一种有别于常人的做法。他并不会把资源的路径或者相关的元数据存储在资源所对应的数据表中,而是采用一个中间表来关联资源与它所需要的附件文件。我们把这个过程称之为attach

以下是笔者公司正式环境中的用户表数据,在线上时候所有用户都是有头像的,然而用户表中的avatar字段却都是空的。

       nickname       | avatar
----------------------+--------
 云长MR.Ma            |
 Lan                  |

这表明,附件的相关数据被存储在别的地方,而Rails通过某种方式来让附件与当前资源关联起来了。ActiveStorage就是背后的魔法师,它为我们的服务提供了两个数据表active_storage_blobsactive_storage_attachments。简单来说active_storage_blobs主要用来存储附件相关的信息,active_storage_attachments则用来存储附件与资源表之间的关联关系。

本来一个表就搞定的东西,现在硬生生多弄了两个表,似乎维护起来更加麻烦了。而且每次获取附件信息的时候不得不多做两次的查询

irb(main):002:0> m = User.first
User Load (0.4ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
irb(main):004:0> m.avatar.service_url
ActiveStorage::Attachment Load (9.4ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = $1 AND "active_storage_attachments"."record_type" = $2 AND "active_storage_attachments"."name" = $3 LIMIT $4  [["record_id", 1], ["record_type", "User"], ["name", "avatar"], ["LIMIT", 1]]
ActiveStorage::Blob Load (2.6ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2  [["id", 300], ["LIMIT", 1]]

不过笔者觉得这样的设计也是有一定道理的,其实它就是把附件也当作数据库资源的一部分去维护了,附件所对应的类为ActiveStorage::Blob,一个附件对应一条数据库记录,这样也便于维护。如果某一个附件需要挂载到资源上面则创建一条ActiveStorage::Attachment的数据记录。同一个附件可以挂载到多个资源上。上面所提到的用户表,它的avatar字段与附件的关联关系都被存储在active_storage_attachments表里面了。

每次对资源的附件进行更替的时候都需要跟数据库打交道,不过ActiveStorage提供了简便的方式让我们可以方便地去维护资源与附件的关联关系(用attach或者detach)。以后如果想要清理附件的资源,似乎就可以通过数据库语句,找到那些没有被关联的附件,通过脚本一次性删除掉。当然用ORM是最方便的

ActiveStorage::Blob.unattached # 找出所有没有被关联的附件

而我们平时用的比较多的做法都是直接把附件保存好(云上或者服务器本地),然后把路径写入到数据库对应的字段中。与ActiveStorage相比,它的开发难度要小一些,也不用处理联表查询的问题,不过ActiveStorage对资源的管控感觉会更严谨一些。孰优孰劣还是开发者自行断定吧,不过上线前还是要想清楚,因为选了一种的话就不太容易回头了,迁移成本会是个问题。

数据表

ActiveStorage::Blob数据表-附件信息的归属地

先来看看active_storage_blobs表,它的主要字段如下

create_table :active_storage_blobs do |t|
  t.string   :key,        null: false
  t.string   :filename,   null: false
  t.string   :content_type
  t.text     :metadata
  t.bigint   :byte_size,  null: false
  t.string   :checksum,   null: false
  t.datetime :created_at, null: false

  t.index [ :key ], unique: true
end

filenamecontent_type, byte_sizecreated_at这些字段其实就是字面上的意思。分别表示附件的名称,类型,字节大小,以及条目的创建时间。

> SELECT filename, content_type, byte_size, created_at FROM active_storage_blobs LIMIT 2 OFFSET 2;

  filename  | content_type | byte_size |         created_at
------------+--------------+-----------+----------------------------
 镶嵌-image | image/jpeg   |     21959 | 2020-11-05 11:58:51.408177
 珠链-image | image/jpeg   |     21959 | 2020-11-05 11:58:51.737428

metadata主要用于存储附件的元数据。有些文件,如图片会包含宽,高这样的元数据,而视频则还会包括播放时长等信息。ActiveStorage可以利用对应的分析器预先对资源进行分析,然后把元数据入库。你可以简单地使用ActiveStorage提供的分析方法ActiveStorage::Blob#analyze对资源进行“手动”分析

> ActiveStorage::Blob.all.reject(&:analyzed?).each(&:analyze) # 我这里只针对那些还没分析过的资源

不管怎么说这都算是个耗时操作,建议异步执行,接下来就会看到数据库里面已经存好了元数据了

huiliu_web_development=# SELECT filename, content_type, metadata FROM active_storage_blobs WHERE content_type = 'video/mp4' LIMIT 1;
       filename       | content_type |                                       metadata
----------------------+--------------+---------------------------------------------------------------------------------------
 1607085026613966.mp4 | video/mp4    | {"identified":true,"analyzed":true,"width":448.0,"height":960.0,"duration":47.838989}
(1 row)

huiliu_web_development=# SELECT filename, content_type, metadata FROM active_storage_blobs WHERE content_type = 'image/jpeg' LIMIT 1;
  filename  | content_type |                           metadata
------------+--------------+--------------------------------------------------------------
 手镯-image | image/jpeg   | {"identified":true,"width":512,"height":512,"analyzed":true}
(1 row)

还剩两个比较奇怪的字段就是keychecksum,这里面key被设置了唯一索引,它的值在该数据表中不允许重复,可以把它理解为附件的唯一标识。而checksum列,直觉告诉我它可能跟某种加密/签名有关,要想了解清楚只能去看源码了。

首先看看key的生成

class ActiveStorage::Blob < ActiveRecord::Base
  # Returns the key pointing to the file on the service that's associated with this blob. The key is the
  # secure-token format from Rails in lower case. So it'll look like: xtapjjcjiudrlk3tmwyjgpuobabd.
  # This key is not intended to be revealed directly to the user.
  # Always refer to blobs using the signed_id or a verified form of the key.
  def key
    # We can't wait until the record is first saved to have a key for it
    self[:key] ||= self.class.generate_unique_secure_token
  end
end

可见,它是通过类方法ActiveStorage::Blob::generate_unique_secure_token直接生成的一组不重复的随机串,主要作为附件在“服务端”(OSS服务,或者服务器)的唯一标识符。

拿阿里云来举例(如果是ActiveStorage搭配阿里云,建议使用李华顺写的activestorage-aliyun),假设我的资源的key值为

> ActiveStorage::Blob.first.key
=> "n58uypyjni90lmzr7oxqog8q9tgt"

那么在阿里云OSS服务中它所对应的URL会是类似这样

> ActiveStorage::Blob.first.service_url
  ActiveStorage::Blob Load (1.8ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" ORDER BY "active_storage_blobs"."id" ASC LIMIT $1  [["LIMIT", 1]]
  Aliyun Storage (0.1ms) Generated URL for file at key: n58uypyjni90lmzr7oxqog8q9tgt (https://huiliu-development.oss-cn-beijing.aliyuncs.com/resources/n58uypyjni90lmzr7oxqog8q9tgt)
=> "https://huiliu-development.oss-cn-beijing.aliyuncs.com/resources/n58uypyjni90lmzr7oxqog8q9tgt"

有点RESTful对吧?

ActiveStorage::Blob对象的构造离不开ActiveStorage::Blob#unfurl

class ActiveStorage::Blob < ActiveRecord::Base
  # ...
  def unfurl(io, identify: true) #:nodoc:
    self.checksum     = compute_checksum_in_chunks(io)
    self.content_type = extract_content_type(io) if content_type.nil? || identify
    self.byte_size    = io.size
    self.identified   = true
  end
end

从方法里面可以看到这里有我们熟悉的数据表字段content_type, byte_size,其实他们就是在这里赋值的,然后还有一个尚未分析的checksum,接下来看看他是怎么回事。先来看看校验和算法:

class ActiveStorage::Blob < ActiveRecord::Base
  # ....
  def compute_checksum_in_chunks(io)
    Digest::MD5.new.tap do |checksum|
      while chunk = io.read(5.megabytes)
        checksum << chunk
      end

      io.rewind
    end.base64digest
  end
end

base64digest的源代码是这样的

def base64digest(str = nil)
  [str ? digest(str) : digest].pack('m0')
end

这....尼玛啥玩意。这么魔幻的写法其实对比一下是这样的

> ['hello'].pack('m0') # in bin/rails c
=> "aGVsbG8="
> echo -n 'hello' | base64 -b 0 # in shell,记得加`-n`不然换行会影响结果
aGVsbG8=

姑且可以把它理解成,把内容md5之后所得到的串以base64的形式输出。这类加密算法我也不太懂这里就不多说了。要知道的是如果是我们平时常见的md5输出,在Ruby里面应该是调用hexdigest这个方法,详情可见文档。故而有

> Digest::MD5.hexdigest 'hello'
=> "5d41402abc4b2a76b9719d911017c592"
> md5 -q -s 'hello'
5d41402abc4b2a76b9719d911017c592

而这里的base64形式,是先得到一个最基本的形式(进行digest),然后再转换成base64的形式,跟hexdigest没有多大关系。大概是这样

> Digest::MD5.digest 'hello' # 先进行 digest处理
=> "]A@*\xBCK*v\xB9q\x9D\x91\x10\x17\xC5\x92"
> ["]A@*\xBCK*v\xB9q\x9D\x91\x10\x17\xC5\x92"].pack('m0') # 再把结果转换成base64的形式
=> "XUFAKrxLKna5cZ2REBfFkg=="

在shell里面就是

> echo -n -e ']A@*\xBCK*v\xB9q\x9D\x91\x10\x17\xC5\x92' | base64 -b0
XUFAKrxLKna5cZ2REBfFkg==

至于那串看不懂的东西']A@*\xBCK*v\xB9q\x9D\x91\x10\x17\xC5\x92'在shell里面怎么生成,我是这样做的

> echo -n 'hello' | openssl md5 -binary
# 此处结果省略,反正看不懂

反正二进制肉眼也无法验证他们是否一致,笔者只好.....

echo -n 'hello' | openssl md5 -binary | base64 -b0
XUFAKrxLKna5cZ2REBfFkg==

好咯,大概原理就是这样。有点扯远了,另外就是compute_checksum_in_chunks里面用了个“分块”小技巧。

....
    while chunk = io.read(5.megabytes)
      checksum << chunk
    end

    io.rewind
....

每次只从输入流里面以5MB为单位读取数据。可能是为了节约内存吧?这种做法在C语言里会比较常见。毕竟如果文件太大的话一次过读入内存中可能会导致溢出。不过如果不考虑内存的问题,其实可以

def compute_checksum_in_chunks_one_step(io)
  str = Digest::MD5.base64digest io.read
  io.rewind
  str
end

> compute_checksum_in_chunks_one_step(StringIO.new('hello'))
=> "XUFAKrxLKna5cZ2REBfFkg=="

效果是一样的。好了这里只是做了校验和,最后这个校验和也会被存储到数据库中(active_storage_blobs数据表的checksum列中)。校验和一般用来检验文件的完整性,在ActiveStorage里面其实也一样。正常来说

> b = ActiveStorage::Blob.first
  ActiveStorage::Blob Load (0.6ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" ORDER BY "active_storage_blobs"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<ActiveStorage::Blob id: 1, key: "n58uypyjni90lmzr7oxqog8q9tgt", filename: "手镯-image", content_type: "image/jpeg", metadata: {"identified"=>true, "widt...

> b.open { |m| m.read }
  Aliyun Storage (297.0ms) Downloaded file from key: n58uypyjni90lmzr7oxqog8q9tgt
=> "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00H\x00H\x00\x00\xFF\xE1\x00LExif\x00\x00MM\x00*\x00\x00\x00\b\x00\x01\x87i\x00\x04\x00\x00\x00\x01\x00\x00\x00\x1A\x00\x00\x00\x00\x00\x03\xA0\x01\x00\x03\x00\x00\x00\

这种情况下校验和是对的,然而如果文件有变动,那么得到的校验和是不一样的,调用相关的方法就会报错(这里我调整校验和来做测试)

> b.update(checksum: 'fake')
   (0.2ms)  BEGIN
  ActiveStorage::Blob Update (0.5ms)  UPDATE "active_storage_blobs" SET "checksum" = $1 WHERE "active_storage_blobs"."id" = $2  [["checksum", "fake"], ["id", 1]]
   (4.2ms)  COMMIT
=> true

> b.open {|m| m.read}
  Aliyun Storage (362.8ms) Downloaded file from key: n58uypyjni90lmzr7oxqog8q9tgt
Traceback (most recent call last):
        1: from (irb):25
ActiveStorage::IntegrityError (ActiveStorage::IntegrityError)

相关的方法放在这

module ActiveStorage
  class Downloader #:nodoc:
    def open(key, checksum:, name: "ActiveStorage-", tmpdir: nil)
      open_tempfile(name, tmpdir) do |file|
        download key, file
        verify_integrity_of file, checksum: checksum
        yield file
      end
    end
    # ...
    private
      def verify_integrity_of(file, checksum:)
        unless Digest::MD5.file(file).base64digest == checksum
          raise ActiveStorage::IntegrityError
        end
      end
  end
end

应该还蛮好理解的,ActiveStorage::Blob对应数据表的分析就先到此为止。简单总结一下,其实只要记住它跟附件的关系就是一对一,并且它会存放附件的一些重要元数据,方便代码的编写。在数据存储之前它会给附件生成一个唯一的key,这个key在数据库里面有唯一的索引。我们日常编程的时候可以通过这个key来获取对应附件的链接,如果需要下载附件,有专门的方法做校验和的检测,如果校验和(checksum)无法配对,报异常,在一定程度上能够保证附件数据的一致性。

ActiveStorage::Attachment数据表-附件与资源间的桥梁

> ActiveStorage::Attachment.table_name
=> "active_storage_attachments"

相比起ActiveStorage::Blob其实ActiveStorage::Attachment要好理解很多。如果说active_storage_blobs用来存放附件本身的一些基本元数据,那么我以为active_storage_attachments就是用来存放附件与资源之间的关联关系。表结构相对来说其实更好理解

create_table :active_storage_attachments do |t|
  t.string     :name,     null: false
  t.references :record,   null: false, polymorphic: true, index: false
  t.references :blob,     null: false

  t.datetime :created_at, null: false

  t.index [ :record_type, :record_id, :name, :blob_id ], name: "index_active_storage_attachments_uniqueness", unique: true
  t.foreign_key :active_storage_blobs, column: :blob_id
end

它会存储active_storage_blobs表的id作为关联的外键。前面也说过它主要是作为中间表来作为附件与资源之间的桥梁。那么它怎么存储资源呢?主要是用了数据表的一种被称为多态的技术。可以参考Rails的Polymorphic Associations,它会存储资源的id以及资源所映射的类名,以此来做到关联多种不同资源。比如我要查看Category, User这两个类所对应的资源分别有哪些附件

huiliu_web_development=# SELECT record_id, record_type, blob_id FROM active_storage_attachments WHERE record_type IN ('Category', 'User');

record_id | record_type | blob_id
-----------+-------------+---------
         1 | Category    |       1
         2 | Category    |       2
         3 | Category    |       3
         4 | Category    |       4
         5 | Category    |       5
         2 | User        |       7

其中record_id就是对应资源的id,而record_type则是资源所映射的类名。其实用Rails的ORM语法来做查询更方便

ActiveStorage::Attachment.where(record_type: [Category.to_s, User.to_s])

看到这里active_storage_attachments作为一个中间表已经十分合格了,它成功地关联了资源以及附件,不过还有一个问题,就是一个资源往往不只有一个字段需要挂载附件。假设说我有一个货品-goods数据表,它可能会同一个字段挂载多张图片,又或者是它不仅需要存储封面图cover还需要存储产品的预览视频videos。这个时候我们就需要知道对应的附件最终会挂载到资源的哪一个字段了。active_storage_attachments数据表通过name列来存储。我拿我本地的数据来举个例子

huiliu_web_development=# SELECT name, record_id, record_type, blob_id FROM active_storage_attachments WHERE record_type = 'Goods' AND record_id = 106;
  name  | record_id | record_type | blob_id
--------+-----------+-------------+---------
 images |       106 | Goods       |     519
 images |       106 | Goods       |     516
 images |       106 | Goods       |     518
 cover  |       106 | Goods       |     521
 images |       106 | Goods       |     517
 videos |       106 | Goods       |     529

该货品有三个字段需要挂载附件,分别是images, cover, videos,其中值为images的记录有多条,表明这个goods表的这个字段挂载了多个附件。

这也带来一个问题,对于线上的数据,在运行一段时间后势必会有很多active_storage_attachments的数据,这个时候如果我们对资源表中的某一个挂载附件的字段进行改名(比如videos => video),那就要小心了。如果我们仅仅是改了数据表名,而没有对线上环境的name列中的数据进行调整的话,那就无法通过新的字段名来获取到原有的附件(应该在部署上线之后通过脚本把数据库goods表对应的记录中的name列数据从videos改成video)。

温馨提示

最后,做个温馨提示吧,可能很多人跟我一样刚开始用ActiveStorage的时候都会不经意犯这个错。

> Goods.find(106).cover.service_url
  Goods Load (0.3ms)  SELECT "goods".* FROM "goods" WHERE "goods"."id" = $1 LIMIT $2  [["id", 106], ["LIMIT", 1]]
  ActiveStorage::Attachment Load (0.2ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = $1 AND "active_storage_attachments"."record_type" = $2 AND "active_storage_attachments"."name" = $3 LIMIT $4  [["record_id", 106], ["record_type", "Goods"], ["name", "cover"], ["LIMIT", 1]]
  ActiveStorage::Blob Load (0.2ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2  [["id", 521], ["LIMIT", 1]]
  Aliyun Storage (0.1ms) Generated URL for file at key: 1arfwjr0njfx538wsa4wkmazcwao (https://huiliu-development.oss-cn-beijing.aliyuncs.com/resources/1arfwjr0njfx538wsa4wkmazcwao)
=> "https://huiliu-development.oss-cn-beijing.aliyuncs.com/resources/1arfwjr0njfx538wsa4wkmazcwao"

可以看到,对单一资源的某一个字段的附件链接进行查询的时候,需要关联三个数据表。WLGQ,要是多查几个

> Goods.where(id: [106, 118, 117]).map { |m| m.cover.service_url }
 ActiveStorage::Attachment Load (0.3ms)
 ...
 ActiveStorage::Blob Load (0.2ms)
 ...
 ActiveStorage::Attachment Load (0.3ms)
 ...
 ActiveStorage::Blob Load (0.2ms)
 ...
 ActiveStorage::Attachment Load (0.3ms)
 ...
 ActiveStorage::Blob Load (0.2ms)

这应该会造成N+1事件。刚上线的时候就是这个没有处理好,再加上附件资源没有做缩放处理导致列表页加载神慢,可以参考一下这篇文章,简单处理一下就好。

> Goods.includes(cover_attachment: :blob). where(id: [106, 118, 117]).map { |m| m.cover.service_url }
  Goods Load (0.5ms)  SELECT "goods".* FROM "goods" WHERE "goods"."id" IN ($1, $2, $3)  [["id", 106], ["id", 118], ["id", 117]]
  ActiveStorage::Attachment Load (0.5ms)  SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" IN ($3, $4, $5)  [["record_type", "Goods"], ["name", "cover"], ["record_id", 117], ["record_id", 106], ["record_id", 118]]
  ActiveStorage::Blob Load (0.4ms)  SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN ($1, $2, $3)  [["id", 521], ["id", 622], ["id", 639]]

尾声

这篇文章主要是对ActiveStorage中的数据表以及它们的工作机制做了简单的分析。笔者也是第一次用,也犯过一些低级错误,总结得不对的地方还望指正。