让我们跟随我一起,通过一个漏洞,深入探索这个网站的源代码。
这篇文章的起因是我觉得研究某个特定的 bug 或许能帮助我更好地理解 devto 的源代码。于是,我一边做研究一边写了这篇文章,结果还写到了其他方面。以下就是正文。
我提到的这个 bug 叫做“在 GitHub 上删除帖子时出现超时”。顾名思义,删除帖子会导致服务器超时,进而导致用户收到错误信息。@peter在他的 bug 报告中补充了一些我们需要注意的细节,以便进行“调查”:这个 bug 并非总是发生(这有利于找到解决方案,确定性总是更好),而且它更容易出现在有很多点赞和评论的文章中。
初步线索:这种情况偶尔会发生,而且通常发生在附带大量数据的文章中。
我们先看看能不能在深入研究代码之前找到更多信息。
注:我写这篇文章是为了解释(并扩展)我研究这件事的方法,而这件事是同时发生的(好吧,虽然持续了好几天,但仍然是同时发生的 :-D),所以当我写下这些发现时,它们对我来说都是新的,就像如果你读到这篇文章时,它们对你来说也是新的一样。
另注:我在这里会交替使用“异步”和“进程外”这两个术语。这里的“异步”指的是“用户无需等待调用执行完毕”,而不是 JavaScript 中的“异步”。更准确的术语应该是“进程外”,因为这些异步调用是由外部进程通过数据库队列,借助名为delayed job 的库/gem 来执行的。
参照完整性
ActiveRecord(Rails 的 ORM)和许多其他对象关系映射器一样,是一个位于关系数据库系统之上的对象层。我们不妨稍作停留,谈谈数据库系统中维护数据意义的一项基本特性:引用完整性。何乐而不为呢,bug 的问题可以稍后再说!
简单来说,参照完整性是一种防御机制,可以防止开发者对关系型数据的结构设计抱有奇特的想法。它禁止插入在关系的主表中没有对应记录的行。通俗地说,它保证了关系中始终存在对应的行:如果你有一个包含 10 个城市的列表表,那么你不应该有一个客户的地址属于一个未知的城市。有趣的是,MySQL 花了十多年才默认启用参照完整性,而 PostgreSQL 当时已经启用了十年。有时候我觉得 MySQL 的早期版本就像是一堆 CSV 文件,上面堆砌了 SQL 语句。我开玩笑的,也许吧。
有了参照完整性,你就可以(基本)放心,数据库不会让你添加僵尸行,会保持关系更新,并且如果你指示它清理,它还会清理。
如何指示数据库执行所有这些操作?其实很简单。我将使用PostgreSQL 10 文档中的一个示例:
CREATE TABLE products (
product_no integer PRIMARY KEY,
name text,
price numeric
);
CREATE TABLE orders (
order_id integer PRIMARY KEY,
shipping_address text,
);
CREATE TABLE order_items (
product_no integer REFERENCES products ON DELETE RESTRICT,
order_id integer REFERENCES orders ON DELETE CASCADE,
quantity integer,
PRIMARY KEY (product_no, order_id)
);
该表order_items有两个外键,一个指向orders另一个products(如果你想知道的话,这是一个典型的多对多示例)。
设计这类表格时,除了“我究竟想用这些数据做什么?”这类显而易见的问题之外,你还应该问自己以下问题:
-
如果删除主表中的一行会发生什么?
-
我是否要删除所有相关行?
-
我是否应该将引用列设置为
NULL?如果是这样,这对我的业务逻辑意味着什么?这NULL对我的数据有意义吗? -
我是否需要将该列设置为默认值?这对我的业务逻辑意味着什么?该列本身是否有默认值?
回顾一下之前的例子,我们实际上是在告诉数据库以下两件事:
-
除非产品未按任何顺序显示,否则无法移除产品。
-
订单可以随时取消,而且他们会把这些东西带进坟墓😀
请记住,即使在 dev.to 这样的环境中,删除操作仍然很快。例如,如果一篇文章的表通过级联指令关联,删除操作也应该很快。数据库变慢通常是因为单个DELETE删除操作触发了数百万(甚至数千万)个其他删除操作。我假设这种情况目前(或将来)不会发生,但由于本节的重点是扩展我们对引用完整性的理解,而不是实际调查错误,所以我们继续深入探讨。
接下来,我们打开控制台,并使用以下命令检查表是否相互链接psql:
$ rails dbconsole
psql (10.5)
Type "help" for help.
PracticalDeveloper_development=# \d+ articles
...
Indexes:
"articles_pkey" PRIMARY KEY, btree (id)
"index_articles_on_boost_states" gin (boost_states)
"index_articles_on_featured_number" btree (featured_number)
"index_articles_on_hotness_score" btree (hotness_score)
"index_articles_on_published_at" btree (published_at)
"index_articles_on_slug" btree (slug)
"index_articles_on_user_id" btree (user_id)
这张表有主键和一些索引,但显然没有外键约束(即参照完整性指标)。请将其与同时具备主键和索引的表进行比较:
PracticalDeveloper_development=# \d users
...
Indexes:
"users_pkey" PRIMARY KEY, btree (id)
"index_users_on_confirmation_token" UNIQUE, btree (confirmation_token)
"index_users_on_reset_password_token" UNIQUE, btree (reset_password_token)
"index_users_on_username" UNIQUE, btree (username)
"index_users_on_language_settings" gin (language_settings)
"index_users_on_organization_id" btree (organization_id)
Referenced by:
TABLE "messages" CONSTRAINT "fk_rails_273a25a7a6" FOREIGN KEY (user_id) REFERENCES users(id)
TABLE "badge_achievements" CONSTRAINT "fk_rails_4a2e48ca67" FOREIGN KEY (user_id) REFERENCES users(id)
TABLE "chat_channel_memberships" CONSTRAINT "fk_rails_4ba367990a" FOREIGN KEY (user_id) REFERENCES users(id)
TABLE "push_notification_subscriptions" CONSTRAINT "fk_rails_c0b1e39717" FOREIGN KEY (user_id) REFERENCES users(id)
我们目前了解到的是:为什么从数据库中删除行时,引用完整性会发挥作用,以及为什么该articles表在数据库层面上与其他任何表都没有明显的关联。但这在 Web 应用程序中也适用吗?让我们更深入一层,看看 Ruby 代码。
ps. 对于 Rails(不记得是从哪个版本开始的了),您还可以通过查看schema.rb文件来查看您定义了哪些外键。
ActiveRecord、关联和回调
既然我们已经了解了什么是引用完整性,如何识别它,并且现在我们知道它与此错误无关,我们可以向上移动一层,检查Article 对象是如何定义的(我会跳过我认为与本文和错误本身无关的内容,尽管我可能错了,因为我对代码库不太熟悉):
class Article < ApplicationRecord
# ...
has_many :comments, as: :commentable
has_many :buffer_updates
has_many :reactions, as: :reactable, dependent: :destroy
has_many :notifications, as: :notifiable
# ...
before_destroy :before_destroy_actions
# ...
def before_destroy_actions
bust_cache
remove_algolia_index
reactions.destroy_all
user.delay.resave_articles
organization&.delay&.resave_articles
end
end
从这段代码中可以获取到很多新信息:
-
Rails(但数据库不知道)知道一篇文章可以有很多评论、缓冲区更新、点赞和通知(在 Rails 术语中,这些被称为“关联”)。
-
反应完全取决于文章,如果文章被删除,反应也会被删除。
-
在对象及其在数据库中的行被销毁之前,有一个回调函数会执行一系列操作(我们稍后会详细介绍)。
-
四种关联
able类型中,有三种是 Rails 称之为多态关联,因为它们允许程序员使用两列(一个字符串,包含对象所属模型类型的名称;以及一个 ID)将多种类型的对象关联到同一行。它们非常方便,但我一直觉得它们使数据库非常依赖于领域模型(由 Rails 设置)。它们可能还需要在关联表中创建复合索引来加快查询速度。
与底层数据库系统类似,ActiveRecord 允许开发者指定当主对象被销毁时,相关对象应该如何处理。根据Rails的文档,它支持以下几种操作:销毁所有相关对象、删除所有相关对象、设置外键为 false或通过抛出错误来限制删除操作。销毁和删除的NULL区别在于,前者会在删除操作之前执行所有相关的回调函数,而后者则会跳过回调函数,仅删除数据库中的相应行。
对于没有关联关系的情况,默认策略dependent是不做任何操作,这意味着保留被引用的行。如果由我决定,默认设置应该是应用程序在用户决定如何处理链接模型之前不会启动,但我并非 ActiveRecord 的设计者。
请记住,数据库的优先级高于代码。即使您在 Rails 层面没有定义任何内容,但数据库配置为自动销毁所有相关行,这些行也会被销毁。这正是为什么花时间学习数据库工作原理非常值得的原因之一 :-)
模型层中我们尚未讨论的最后一部分是回调函数,这很可能是 bug 出现的地方。
臭名昭著的回访
此销毁前DELETE回调函数将在向数据库发出语句之前执行:
def before_destroy_actions
bust_cache
remove_algolia_index
reactions.destroy_all
user.delay.resave_articles
organization&.delay&.resave_articles
end
缓存清除
回调函数首先调用一个方法,该方法bust_cache会依次调用 Fastly API 六次来清除文章的缓存(每次调用 bust 方法都会产生两次 HTTP 请求)。它还会对同一个 API 进行大量进程外调用(大约 20-50 次,具体次数取决于文章状态和标签数量),但这些调用无关紧要,因为用户不会等待它们完成。
需要说明一点:按下删除文章的按钮后,总是会发出六个 HTTP 请求。
索引删除
dev.to 使用 Algolia 进行搜索,该调用remove_algolia_index执行以下操作:
-
调用 ` algolia_remove_from_index!`函数,该函数又会调用 Algolia HTTP API 的“异步”版本,而实际上,异步版本会向 Algolia 发起一个(快速的)同步调用,无需等待索引在 Algolia 端被清除。这仍然是一个同步调用,会增加用户的延迟。
-
还两次调用 Algolia 的 HTTP API 以获取其他索引。
因此,加上之前对 Fastly 的 6 个 HTTP 调用,目前该进程共调用了 9 个 API。
反应破坏
第三步,reactions.destroy_all顾名思义,会销毁文章的所有反应。在 Rails 中,它destroy_all会遍历所有对象,并对每个对象调用 `destroy` 方法,这会激活所有对应的 `destroy` 回调函数,以便进行正确的清理。Reaction模型有两个回调before_destroy函数:
class Reaction < ApplicationRecord
# ...
before_destroy :update_reactable_without_delay
before_destroy :clean_up_before_destroy
# ...
end
我费了点功夫才弄明白第一个方法的作用(我不喜欢 Rails 的一点就是它到处冒出各种“魔法方法”,这让重构变得更难,也加剧了模型和各种 gem 之间的耦合)。它update_reactable_without_delay调用了`update_reactable`(默认情况下已声明为异步函数),绕过了队列。结果是用户需要等待的标准内联调用。
-
update_reactable如果文章已发布,则重新计算文章的评分(这次是在进程外执行,由于文章即将被删除,因此应该避免此操作)。然后(回到内联流程),调用 Algolia 对文章进行两次重新索引,从 Fastly 缓存中移除评论(每次清除缓存都需要两次 Fastly 调用),清除另一个缓存(需要两次 HTTP 调用),并可能更新文章的某一列(由于文章即将被删除,因此可能不需要更新)。总共需要 6 次 HTTP 调用:一次异步 HTTP 调用(第一次调用 Algolia),一次调用 Algolia,以及四次调用 Fastly。让我们把用户需要等待的这 5 次调用都记录下来。 -
clean_up_before_destroy第三次在 Algolia 上重新索引该文章。
总结一下:删除一个反应会产生 6 次 HTTP 请求。如果一篇文章有 100 个反应……嗯,你自己算算吧。
假设这篇文章有 1 个点赞,加上之前的请求,总共大约有 15 个 HTTP 请求:
-
6. 破解文章缓存
-
3. 从索引中删除文章
-
6. 针对文章附带的反应
我偶然发现,在使用gist 调试 net/http 调用时,还有一个额外的 HTTP 调用,它调用Stream.io API从用户的动态中删除点赞。总共有 16 个 HTTP 调用。
当一个反应被销毁时,就会发生这种情况(我在本地安装中添加了很棒的 gem httplog ):
[httplog] Sending: PUT https://REDACTED.algolia.net/1/indexes/Article_development/25
[httplog] Data: {"title":" The Curious Incident of the Dog in the Night-Time"}
[httplog] Connecting: REDACTED.algolia.net:443
[httplog] Status: 200
[httplog] Benchmark: 0.357128 seconds
[httplog] Response:
{"updatedAt":"2018-10-09T17:23:44.151Z","taskID":945887592,"objectID":"25"}
[httplog] Sending: PUT https://REDACTED.algolia.net/1/indexes/searchables_development/articles-25
[httplog] Data: {"title":" The Curious Incident of the Dog in the Night-Time","tag_list":["discuss","security","python","beginners"],"main_image":"https://pigment.github.io/fake-logos/logos/medium/color/8.png","id":25,"featured":true,"published":true,"published_at":"2018-09-30T07:44:48.530Z","featured_number":1538293488,"comments_count":1,"reactions_count":0,"positive_reactions_count":0,"path":"/willricki/-the-curious-incident-of-the-dog-in-the-night-time-3e4b","class_name":"Article","user_name":"Ricki Will","user_username":"willricki","comments_blob":"Waistcoat craft beer pickled vice seitan kombucha drinking. 90's green juice hoodie.","body_text":"\n\nMeggings tattooed normcore kitsch chia. Fixie migas etsy hashtag jean shorts neutra pork belly. Vice salvia biodiesel portland actually slow-carb loko chia. Freegan biodiesel flexitarian tattooed.\n\n\nNeque. \n\n\nBefore they sold out diy xoxo aesthetic biodiesel pbr\u0026amp;b. Tumblr lo-fi craft beer listicle. Lo-fi church-key cold-pressed.\n\n\n","tag_keywords_for_search":"","search_score":153832,"readable_publish_date":"Sep 30","flare_tag":{"name":"discuss","bg_color_hex":null,"text_color_hex":null},"user":{"username":"willricki","name":"Ricki Will","profile_image_90":"/uploads/user/profile_image/6/22018b1a-7afa-47c1-bbae-b829977828e4.png"},"_tags":["discuss","security","python","beginners","user_6","username_willricki","lang_en"]}
[httplog] Status: 200
[httplog] Benchmark: 0.031995 seconds
[httplog] Response:
{"updatedAt":"2018-10-09T17:23:44.426Z","taskID":945887612,"objectID":"articles-25"}
[httplog] Sending: PUT https://REDACTED.algolia.net/1/indexes/ordered_articles_development/articles-25
[httplog] Data: {"title":" The Curious Incident of the Dog in the Night-Time","path":"/willricki/-the-curious-incident-of-the-dog-in-the-night-time-3e4b","class_name":"Article","comments_count":1,"tag_list":["discuss","security","python","beginners"],"positive_reactions_count":0,"id":25,"hotness_score":153829,"readable_publish_date":"Sep 30","flare_tag":{"name":"discuss","bg_color_hex":null,"text_color_hex":null},"published_at_int":1538293488,"user":{"username":"willricki","name":"Ricki Will","profile_image_90":"/uploads/user/profile_image/6/22018b1a-7afa-47c1-bbae-b829977828e4.png"},"_tags":["discuss","security","python","beginners","user_6","username_willricki","lang_en"]}
[httplog] Status: 200
[httplog] Benchmark: 0.047077 seconds
[httplog] Response:
{"updatedAt":"2018-10-09T17:23:44.494Z","taskID":945887622,"objectID":"articles-25"}
[httplog] Sending: PUT https://REDACTED.algolia.net/1/indexes/Article_development/25
[httplog] Data: {"title":" The Curious Incident of the Dog in the Night-Time"}
[httplog] Status: 200
[httplog] Benchmark: 0.029352 seconds
[httplog] Response:
{"updatedAt":"2018-10-09T17:23:44.541Z","taskID":945887632,"objectID":"25"}
[httplog] Sending: PUT https://REDACTED.algolia.net/1/indexes/searchables_development/articles-25
[httplog] Data: {"title":" The Curious Incident of the Dog in the Night-Time","tag_list":["discuss","security","python","beginners"],"main_image":"https://pigment.github.io/fake-logos/logos/medium/color/8.png","id":25,"featured":true,"published":true,"published_at":"2018-09-30T07:44:48.530Z","featured_number":1538293488,"comments_count":1,"reactions_count":0,"positive_reactions_count":1,"path":"/willricki/-the-curious-incident-of-the-dog-in-the-night-time-3e4b","class_name":"Article","user_name":"Ricki Will","user_username":"willricki","comments_blob":"Waistcoat craft beer pickled vice seitan kombucha drinking. 90's green juice hoodie.","body_text":"\n\nMeggings tattooed normcore kitsch chia. Fixie migas etsy hashtag jean shorts neutra pork belly. Vice salvia biodiesel portland actually slow-carb loko chia. Freegan biodiesel flexitarian tattooed.\n\n\nNeque. \n\n\nBefore they sold out diy xoxo aesthetic biodiesel pbr\u0026amp;b. Tumblr lo-fi craft beer listicle. Lo-fi church-key cold-pressed.\n\n\n","tag_keywords_for_search":"","search_score":154132,"readable_publish_date":"Sep 30","flare_tag":{"name":"discuss","bg_color_hex":null,"text_color_hex":null},"user":{"username":"willricki","name":"Ricki Will","profile_image_90":"/uploads/user/profile_image/6/22018b1a-7afa-47c1-bbae-b829977828e4.png"},"_tags":["discuss","security","python","beginners","user_6","username_willricki","lang_en"]}
[httplog] Status: 200
[httplog] Benchmark: 0.028819 seconds
[httplog] Response:
{"updatedAt":"2018-10-09T17:23:44.612Z","taskID":945887642,"objectID":"articles-25"}
[httplog] Sending: PUT https://REDACTED.algolia.net/1/indexes/ordered_articles_development/articles-25
[httplog] Data: {"title":" The Curious Incident of the Dog in the Night-Time","path":"/willricki/-the-curious-incident-of-the-dog-in-the-night-time-3e4b","class_name":"Article","comments_count":1,"tag_list":["discuss","security","python","beginners"],"positive_reactions_count":1,"id":25,"hotness_score":153829,"readable_publish_date":"Sep 30","flare_tag":{"name":"discuss","bg_color_hex":null,"text_color_hex":null},"published_at_int":1538293488,"user":{"username":"willricki","name":"Ricki Will","profile_image_90":"/uploads/user/profile_image/6/22018b1a-7afa-47c1-bbae-b829977828e4.png"},"_tags":["discuss","security","python","beginners","user_6","username_willricki","lang_en"]}
[httplog] Status: 200
[httplog] Benchmark: 0.02821 seconds
[httplog] Response:
{"updatedAt":"2018-10-09T17:23:44.652Z","taskID":945887652,"objectID":"articles-25"}
[httplog] Connecting: us-east-api.stream-io-api.com:443
[httplog] Sending: DELETE http://us-east-api.stream-io-api.com:443/api/v1.0/feed/user/10/Reaction:7/?api_key=REDACTED&foreign_id=1
[httplog] Data:
[httplog] Status: 200
[httplog] Benchmark: 0.336152 seconds
[httplog] Response:
{"removed":"Reaction:7","duration":"17.84ms"}
如果你数一下,会发现是 7 个,而不是 16 个。这是因为对 Fastly 的调用只在生产环境中执行。
重新保存文章
`User.resave_articles`会刷新用户的其他文章,并且是在进程外调用的,所以我们目前不关心它。如果文章属于某个组织,也会对组织进行同样的操作,但我们同样不关心。
让我们回顾一下目前已知的信息。每次删除文章都会触发一个回调函数,该函数会执行许多操作,这些操作会影响第三方服务,从而保证本网站的运行速度,同时还会更新一些我没有深入研究的计数器 :-D。
文章删除后会发生什么?
在回调处理完毕、所有缓存都已更新且数据库中的互动记录也已删除之后,我们还需要检查被删除文章的其他关联项会发生什么变化。正如您所知,每篇文章都可能包含评论、互动(现在已经删除)、缓冲区更新(不确定具体是什么)和通知。
我们来看看销毁一篇文章后会发生什么,能否从中找到其他线索。我用摘要替换了冗长的日志:
> art = Article.last # article id 25
> art.destroy!
# its tags are destroyed, this is handled by "acts_as_taggable_on :tags"...
# a bunch of other tag related stuff happens, 17 select calls...
# the aforamentioned HTTP calls for each reaction are here too...
# there's a SQL DELETE for each reaction...
# the user object is updated...
# a couple of other UPDATEs I didn't investigate but which seem really plausible...
# the HTTP calls to remove the article itself from search...
# the article is finally deleted from the db...
# the article count for the user is updated
DELETE除了我最初的概述中完全忘记了标签的销毁(它们相当于数据库中的一个和另一个)之外,UPDATE我想说,当一篇文章被删除时,会发生很多事情。
控制台中未找到的其他对象会发生什么情况?
如果你还记得我之前说过的话,在 Rails 的关系中,所有没有明确标记为“依赖”的对象都会在主对象销毁后仍然存在,所以它们都保存在数据库中:
PracticalDeveloper_development=# select count(*) from comments where commentable_id = 25;
count
-------
3
PracticalDeveloper_development=# select count(*) from notifications where notifiable_id = 25;
count
-------
2
PracticalDeveloper_development=# select count(*) from buffer_updates where article_id = 25;
count
-------
1
我认为我们可以比较有把握地说,如果这篇文章在被删除之前真的非常受欢迎,获得了大量的点赞、评论和通知,那么引发这篇文章的问题很可能会显现出来。
暂停
我在对该问题的评论中提到的另一个因素是 Heroku 的默认超时设置。我记得 dev.to 运行在 Heroku 上,Heroku 对 HTTP 请求设置了30 秒的超时时间(路由器处理完请求后,这相当于给你的应用代码设置了一个计时器)。如果应用在 30 秒内没有响应,就会超时并返回错误。
dev.to 巧妙地利用机架超时默认服务超时(15 秒)将此超时时间缩短了一半。
简而言之:如果点击“删除文章”按钮后,服务器在 15 秒内未完成请求,则会抛出超时错误。考虑到一篇热门文章可能会触发数十次 HTTP 请求,您就能理解为什么在某些情况下会遇到 15 秒的超时限制。
概要
让我们回顾一下目前为止我们了解到的关于文章被删除后会发生什么情况:
-
如果文章有数百万条相关记录(这种情况在本例中不太可能),则引用完整性可能是一个影响因素。
-
Rails 按顺序删除关联对象是一个因素(考虑到它还必须先将这些对象从数据库加载到 ORM 中才能删除它们,因为如果要触发各种回调,就必须这样做)。
-
内联回调和HTTP调用是另一个因素。
-
Rails一点也不智能,因为它本可以减少对数据库的调用次数(例如,通过缓冲
DELETE所有反应的语句并使用IN子句)。 -
Rails 的一些特殊功能有时确实很烦人😛
可能的解决方案
我暂时就到此为止,因为我对代码库还不熟悉(不过这次之后肯定会更熟悉 :D),而且我认为这可能是一个有趣的“集体”练习,因为它不是一个需要“昨天”就修复的严重错误。
一开始,最简单的解决方案就是把文章移除时发生的所有操作都移到一个进程外的调用中,方法是把所有操作都委托给一个任务,然后由队列管理器来处理。毕竟,用户只是需要把文章从他们的视图中移除。正确的移除操作可以通过工作进程来完成。虽然我不确定我是否考虑到了所有情况(正如你所看到的,我是偶然发现标签的)以及所有可能的影响,但我认为这只是一个权宜之计。它能暂时解决用户的问题,但实际上却掩盖了用户反馈的问题。
另一种可能的解决方案是将删除操作拆分为两个主要部分:更新或清空缓存,以及从数据库中删除数据行。缓存可以全部在进程外销毁,这样用户就无需等待 Fastly 或 Algolia(或许只需要 Stream.io?我不确定)。这需要进行一些重构,因为我提到的部分代码也被应用程序的其他部分使用。
更完善的解决方案是在第二个方案的基础上更进一步,清理所有残留项(注释、通知和缓冲区更新),但它们最初被遗留在那里可能另有原因。这三个实体都可以放在单独的任务中移除,因为其中两个在销毁之前都有回调函数,会触发我尚未研究的其他操作。
这应该足以确保用户不会再遇到烦人的超时错误。为了更进一步,我们还可以研究一下 ActiveRecord 会为DELETE从数据库中删除的每个对象发出一个单独的异常,但这目前来说工作量太大。我会先在某个地方添加注释,如果需要的话,可以在重构完成后再来处理。
结论
如果你还在看,谢谢你。我花了好长时间才写完这篇文章 :-D
我并没有什么惊人的结论。我希望这次对 dev.to 源代码的深入研究至少能起到一些作用。对我来说,这是一次很好的学习机会,让我能够写一些非 Rails 开发者可能不了解的内容,但更重要的是,希望能帮助到潜在的贡献者。
我非常希望得到一些反馈 ;-)
文章来源:https://dev.to/rhymes/come-with-me-on-a-journey-through-this-websites-source-code-by-way-of-a-bug-odj