Disassembling Rails — How does ActionText deal with file upload?
(This post is based on Rails 6.0.0.rc2) TBH, I wasn’t that interested in the ActionText
component, because it's very unlikely that I'll use it either at work or on my side projects. But recently I took some time to study it in order to solve https://github.com/rails/rails/issues/36177. It's kind of a rabbit hole and there aren't many materials about ActionText
online at this point. So I decided to write down what I found and I hope this can help those who just started to try out ActionText
. Also, because in this post I'll talk about uploading files, this means that some of it will also cover ActiveStorage
component (mostly about direct upload).
I will separate the whole workflow in following steps
- Upload
- Parse, Transform and Attach
- Render
(I also found a gotcha during my research, let me show you at the end of this post 😉)
Upload
This part doesn’t have much to do with ActionText
. But I think it’s also very fun (really?) to know how the images get into the text area 😄
- Rails’ JS library will first send a post request to `/rails/active_storage/direct_uploads` (this endpoint is configurable) with file metadata like
{
"blob"=>{
"filename"=>"goby-logo.png",
"content_type"=>"image/png", "byte_size"=>5109,
"checksum"=>"H4+4U/vWyxzVUOmMLG9Ghg=="
},
"direct_upload"=>{
"blob"=>{
"filename"=>"goby-logo.png",
"content_type"=>"image/png",
"byte_size"=>5109,
"checksum"=>"H4+4U/vWyxzVUOmMLG9Ghg=="
}
}
}
2. Rails will then create an ActiveStorage::Blob
record and return a direct upload url and signed id of that blob.
3. Once received the direct upload url, the JS library will handle the file upload.
4. Meanwhile, the image will be inserted in the Trix
editor's text area and wrapped in a figure
element. The metadata of uploaded file will be put inside the figure's data-trix-attachment
attribute:
<figure
data-trix-attachment={
“contentType”:”image/png”,
”filename”:”goby-logo.png”,
”filesize”:5109,
”height”:142,
”sgid”:”BAh7CEkiCGdpZAY6BkVUSSI8Z2lkOi8vYWN0aW9udGV4dG5wbHVzMS9BY3RpdmVTdG9yYWdlOjpCbG9iLzM_ZXhwaXJlc19pbgY7AFRJIgxwdXJwb3NlBjsAVEkiD2F0dGFjaGFibGUGOwBUSSIPZXhwaXJlc19hdAY7AFQw—4e7f74086b3917849b01b3136fd6465220a11804”,
”url”:”http://localhost:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDQT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--9ec5e5aa73562eb16a1b06ad42c83e1a9d94c3dc/goby-logo.png”,
”width”:142
}>
Till this point, the user can see the uploaded image on the page, the file is uploaded to the storage and we have the image’s blob record in database 🎉.
However, Rails still doesn’t know where to attach the blob record until we save the text content and in next step.
Parse, Transform and Attach
After receiving the text content, we can’t just save it into the database because there still are works haven’t been done, like:
- The image’s blob record is an orphan right now, we need to find a way to attach it to something (an
ActionText::RichText
record). - Since we don’t always display our text content inside a
Trix
editor, it's not a good idea to store thoseTrix
elements directly. SoActionText
needs to perform some transformations beforehand.
And in order to do the above things, we need to parse the content first!
Parse
When an ActionText::RichText
object is initialized, the text content will be used to initialize an ActionText::Content
object (let's just call it content object in the later sections).
# rails/actiontext/app/models/action_text/rich_text.rb
module ActionText
class RichText < ActiveRecord::Base
serialize :body, ActionText::Content
end
end
That content object holds the content as a tree structure, so it’ll be easier to search/replace elements inside it.
Transform
ActionText
will go through the content tree and look for elements that matches[data-trix-attachment]
selector.- When it finds the node, it’ll parse the value of
data-trix-attachment
into a series of attributes. - It then uses those attributes to initialize an
action-text-attachment
node. The node would look like this when serialized into html:
<action-text-attachment
sgid="BAh7CEkiCGdpZAY6BkVUSSI8Z2lkOi8vYWN0aW9udGV4dG5wbHVzMS9BY3RpdmVTdG9yYWdlOjpCbG9iLzQ_ZXhwaXJlc19pbgY7AFRJIgxwdXJwb3NlBjsAVEkiD2F0dGFjaGFibGUGOwBUSSIPZXhwaXJlc19hdAY7AFQw--683e065a43885a4d71de889936f184999aa4e208"
content-type="image/png"
url="http://localhost:3000/rails/active_storage/blobs/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--0e0e2e03765b64517af21a00ca5cc9771b2b2984/goby-logo.png"
filename="goby-logo.png"
filesize="5109"
width="142"
height="142"
presentation="gallery">
</action-text-attachment>
Attach
Now it’s time to attach image blobs on our rich text record! But how should we find all the attachments? It turns out the content object has a method call attachables
, which returns all the action-text-attachment
element it holds (by going through the tree and collect them).
And there actually are 3 types of attachments in ActionText
:
- Assets uploaded via
ActiveStorage
Trix
partials- Remote images
ActionText
will find all the these attachables, grep ActiveStorage::Blob
and attach them on the ActionText::RichText
record before saving it.
# rails/actiontext/app/models/action_text/rich_text.rb
module ActionText
class RichText < ActiveRecord::Base
before_save do
self.embeds = body.attachables.grep(ActiveStorage::Blob).uniq if body.present?
end
end
end
Signed Global IDs
I’m not sure if you’ve noticed that I missed a very important step. Some of you may still wonder: When did the attachable became a blob record?
To explain this, we need to first understand the sgid
attribute displayed in the action-text-attachment
node earlier.
The name sgid
means Signed Global IDs. What does that mean exactly? Well, it means
- The value is an Global Identification across the app.
- It’s generated from the blob object directly.
- It contains everything we need to find that blob object, including the model name and the record id.
- It’s signed so other people won’t be able to tamper with your data. You can inspect the signed global id in your console like:
sgid = ActiveStorage::Blob.find(id).to_sgid(for: "attachable").to_s
SignedGlobalID.parse(sgid, for: "attachable").model_id #=> "1"
GlobalID::Locator.locate_signed sgid, for: "attachable" #=> #<ActiveStorage::Blob id: 1.....>
For more detail about global id or signed global ids, you should checkout the globalid gem.
So this is why ActionText
can find your attachment blob without having an id or a model name.
# rails/actiontext/lib/action_text/attachable.rb
module ActionText
module Attachable
class << self
def from_attachable_sgid(sgid, options = {})
method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed
record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME))
record || raise(ActiveRecord::RecordNotFound)
end
end
end
end
Render
After understanding how the text content is processed and stored, it’s pretty easy to guess how would ActionText
render the content:
- Parse the content into a tree
- Find all attachment nodes
- And use attachment’s
sgid
to perform a global search to find the image blob.
And here comes the gotcha I mentioned at the beginning of this post: n+1 queries!!!!!
Although ActionText
provides some scopes like
Message.with_rich_text_content_and_embeds
to eager load the contents plus their blobs. You will still find your application creating n+1 queries when rendering you text content, why?
It’s because it uses sgid
to find the blob records!
# rails/actiontext/lib/action_text/attachable.rb
module ActionText
module Attachable
class << self
def from_attachable_sgid(sgid, options = {})
method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed
record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME))
record || raise(ActiveRecord::RecordNotFound)
end
end
end
end
It’s essentially like
ActiveStorage::Blob.find(decoded_id)
Since this is done individually when going through each attachment element, it gets 0 benefit from the eager loading scope. In fact, in some cases, it’s even worse to use those scopes because it creates extra queries to eager load the data we couldn’t use. (This is actually the issue that https://github.com/rails/rails/issues/36177 reports)
Conclusion
After studying and playing with ActionText
for hours, I think it's a well designed component and I would say it's definitely the best choice when you need a rich text editor. However, I also think there's some room for improvements, like the n+1 queries issue mentioned earlier. And finally, I'd love to hear some feedbacks from those who's already started using it!! Let's make this component more mature and easy to use 😄