如何从现有代码创建Ruby on Rails gem
当开发者面临问题时,几乎本能地会去搜索其他开发者成功使用过的解决方案。(如果有一个现成的库可以直接插入我们的代码中为我们节省一些时间,那就更好了,对吧?)
在这篇博客文章中,我将使用我之前的博客文章(如何在Rails的Active Storage中使用FilePond)并将那里的代码转换成一个gem。在阅读这篇文章时,请记住这是我的方法。没有一种单一的方法来做这件事,你会注意到当你阅读不同gem的各种源代码时,作者有各种各样的偏好。然而,我将概述一些一般的最佳实践。
顺便说一下,如果你是Ruby领域的新手,我们称库为”gem”。例如,Rails本身就是一个gem。所以从现在开始,我只会将Ruby库称为gem。
我们要构建什么
如果你还没有阅读前一篇博客文章(如何在Rails的Active Storage中使用FilePond),我建议你现在在新标签页中阅读。它将为你提供一些关于我们在这里构建什么的背景。
简要地,这里是一个总结:
- 创建(使用我们前一篇文章的代码)一个FilePond JavaScript库的集成库。这将包括用于我们自定义控制器的Ruby代码和加载JavaScript库的代码。
- 添加测试来检查我们的控制器、JavaScript代码的加载,以及系统测试来检查我们集成的功能。
- 记录我们的gem以便其他人可以使用它。
- 将gem发布到RubyGems.org。
何时创建gem
项目中有太多依赖是一种确定的方式,会让你长期面临困难。它影响代码的可维护性,并使升级(例如Rails项目)变得更加困难。
话虽如此,有时使用gem确实有意义。这里有几个原因:
打包代码以便复用
如果你在多个项目中添加相同的代码——比如,在一个组织或公司内——而且对这组特定代码几乎没有定制,那么将它们打包成gem可能是有意义的。它对齐这个共享代码库,便于升级和维护。
典型的场景可能是:
- 计费系统
- UI元素
- 部署样板代码
功能范围有限
你通常想要避免的一个场景是创建一个添加太多功能的gem。范围有限的gem更容易维护、理解和使用。
这方面的例子可能是:
- API包装器(例如,外部服务、数据库等)
- 用于模拟网络调用的测试助手
- 特定功能(例如,货币转换)的实用方法
和编程中的其他一切一样,上述原因和例子总是有例外。我通常会问自己的问题是:
- 这个gem能独立运作吗,特别是针对一小部分功能?或者,
- 这个gem是否添加了一个功能——虽然耦合(比如与Ruby on Rails)——但范围有限且有相对稳定的实现?
说完这些,让我们谈谈创建Ruby gem时的一些一般考虑。
Gem创作考虑
当你发布任何打算供他人使用的代码时,有几件事需要考虑。在我们的讨论中,我假设你正在考虑将你的gem开源。
我将如何命名我的gem?
选择名称是个人偏好。然而,如果你计划发布到RubyGems.org,你仍然想要尊重拥有现有商标的组织和个人的命名空间。
对于我自己,我更喜欢描述性的(即无聊的)名称。在与公司咨询时,我见过一些非常大的Gemfile文件。我经常看到各种各样的gem名称,我不得不手动检查它们作为依赖项的目的是什么。
对于我们的FilePond集成gem,我们将称之为filepond-rails。
你将选择什么许可证?
有许多类型的开源许可模式。我建议你查看开源倡议的许可证和标准页面了解详情。
| 项目 | 许可证 |
|---|---|
| Ruby on Rails | MIT |
| Bundler | MIT |
| Devise | MIT |
| Sidekiq | LGPLv3 |
| aws-sdk-core | Apache |
| Nokogiri | MIT |
| Faraday | MIT |
| Puma | BSD 3-Clause |
你选择的许可证类型可能取决于你对开源的理念、特定的商业目标,或者完全不同的东西。对于我们在本教程中构建的gem,我们将使用MIT许可证。
你的gem将支持哪些Rails版本?
如果你正在创建一个可以在任何Ruby程序中使用的gem,你还需要考虑特定的Ruby版本。然而,因为我们构建的上下文是针对Rails的,我们的考虑将主要集中在Rails版本上。
在发布gem时,预期你会在gemspec文件中定义依赖要求(我们马上会讲到)。请记住,你想要支持的Ruby和Rails版本越多,确保向后和向前兼容性的工作可能就越多。
如果你查看一些gem的源代码,你会看到这样的代码:
if Rails::VERSION::MAJOR < 7
# 旧Rails的实现
else
# Rails 7+的实现
end
对于filepond-rails,我们将支持Rails 7及以上版本。
其他人开发或贡献的说明
另一个要考虑的事情是你是否希望其他人fork并为你的项目做贡献。对于开源项目,这通常是个好主意,因为它为其他开发者提供了一条路径来提供pull request并帮助修复bug。
为了让其他人更容易参与你的项目,我通常喜欢添加Docker设置和关于如何快速启动项目的清晰说明。
对于filepond-rails,这是我要做的:
# 通过"dummy"应用运行gem,执行:
docker compose up
# 进入开发环境,在那里你可以运行bin/rails g controller和其他命令:
docker compose run app bash
脚手架你的gem
要为filepond-rails创建我们的Rails gem,有几种方法可以做到这一点。
可能最简单的方法是按照Rails Guide: Getting Started with Engines中的说明进行操作。GitHub上也有几个入门模板可供使用,对于创建通用gem(即非Rails特定的)很有用。
对于我们的项目,我们将使用Rails Guide这样做:
rails plugin new filepond-rails --mountable
上面的rails命令直接来自https://guides.rubyonrails.org/engines.html#generating-an-engine。
我们指定了--mountable选项,这样我们就可以将自定义控制器(及其路由)“挂载”到宿主应用程序。(宿主应用程序是使用我们gem的Rails应用。)
生成器创建了几个文件和文件夹来帮助我们开始。让我们看看这些文件:
app目录和一个类似于你常规Rails应用程序的内部结构(例如,有assets、controllers、helpers、jobs、mailers等)。- 用于保存bin stubs的
bin目录。 config目录,有一个单独的route.rb文件,定义了你gem的可挂载路由。lib目录,通常包含你gem的所有自定义代码的大部分。在这个目录中,Rails已经搭建了一个命名空间常量。对于我们的filepond-railsgem,我们有一个FilePond::Rails命名空间。还有一个tasks目录用于保存gem特定的Rake任务。- 用于测试的
test目录。 filepond-rails.gemspec用于描述我们的gem,它做什么,作者,许可证,gem服务器配置和依赖。有关此文件和可以放入其中的各种设置的更多信息,请查看RubyGems.org的规格指南。Gemfile指定你开发环境需要的任何其他gem。通常,对于开发依赖,我使用gemspec文件的add_development_dependency指令。MIT-LICENSE是Rails生成器创建的默认许可证。根据你的需要修改或更改。Rakefile加载内置的gem Rake任务、dummy应用Rake任务和你定义的任何其他自定义Rake任务。README.md应该包含你gem的描述、gem的使用说明和其他相关信息。
创建我们的代码
本节将描述我们对脚手架代码进行的各种更改,并将这些通用占位符文件和文件夹转换为可发布的库。让我们开始:
1. 定义gemspec
我们的第一步应该是自定义gemspec。
你会注意到gem的版本是这样定义的:
spec.version = Filepond::Rails::VERSION
gemspec动态引用你定义为Filepond::RAILS::VERSION的内容。这是在lib/filepond/rails/version.rb中定义的:
module Filepond
module Rails
VERSION = "0.1.0"
end
end
对于第一个版本,我通常就保留为0.1.0。然而,一旦我们完成了gem的第一个版本,我会将其提升到1.0.0,因为它代表了第一个真正的发布。
在这里使用语义版本控制(SemVer)是个好主意。如果你不熟悉这个,我建议你看看https://semver.org/。
本质上,你在软件中指定的”版本”应该对应某种含义。以下是SemVer网站上的描述:
- 主版本号:当你做了不兼容的API更改
- 次版本号:当你以向后兼容的方式添加功能
- 补丁版本号:当你做了向后兼容的bug修复
注意:Rails本身也遵循SemVer模式来发布新版本。
对于其余的gemspec,已经有很好的内联注释来帮助你入门。对于我们的gem,你可以在这里查看我们完成的gemspec:https://github.com/Code-With-Rails/filepond-rails/blob/main/filepond-rails.gemspec。
2. 复制我们的代码
因为我们从一个已经运行的FilePond集成代码库开始,我们可以从那里将代码复制到我们的脚手架项目目录中。
如果你正在遵循某种测试驱动开发(TDD)工作流程,你可能想从添加测试开始。对我来说,至少在Rails gem方面,通常来自我想从另一个项目中提取的现有代码,所以我的工作流程通常是先复制现有代码然后添加测试。
从我们现有的代码(你可以在https://github.com/code-With-Rails/filepond-demo看到),我们将复制以下内容:
ingress_controller.rb处理与FilePond相关的特定功能routes.rb为上述控制器定义一些路由- FilePond引用
- 实例化FilePond的JavaScript代码
让我们快速讨论每个文件以及我们如何修改它们以适应我们的gem:
ingress_controller.rb
这里不需要修改太多,因为现有代码已经可以工作。然而,我们需要将常量从IngressController命名空间化为Filepond::Rails::IngressController。这样我们可以将属于我们gem的所有内容保持在自己的命名空间中,避免与宿主应用程序可能的命名冲突。
然后应该将此文件放在app/controllers/filepond/rails/ingress_controller.rb。
routes.rb
在我们的原始代码中,我们只是将路由作为主应用程序的一部分添加。在gem中,我们需要像这样将其”挂载”到宿主应用程序:
# 宿主应用程序的config/routes.rb
Rails.application.routes.draw do
mount Filepond::Rails::Engine, at: '/filepond'
# 其他路由等
end
你可以参考Rails Guide了解更多关于引擎挂载的信息。
然而,在我们自己gem的源代码中,我们需要配置哪些路由被挂载。我们像这样修改config/routes.rb:
Filepond::Rails::Engine.routes.draw do
# 有关FilePond端点的详细信息,请参阅https://pqina.nl/filepond/docs/api/server
# https://pqina.nl/filepond/docs/api/server/#fetch
post 'active_storage/fetch', to: 'ingress#fetch'
# https://pqina.nl/filepond/docs/api/server/#remove
delete 'active_storage/remove', to: 'ingress#remove'
end
与我们的原始代码比较,你可以看到它基本上是一样的。我们在这里做了改进,添加了active_storage来标识这些FilePond自定义路由是用于ActiveStorage的(这是我们集成的意图)。注意这是个人(即我的)偏好。
这里的关键信息是仔细考虑如何命名你的路由。因为路由(通常)无论如何都挂载在子路径中(回想一下mount Filepond::Rails::Engine, at: '/filepond'中的at哈希选项),事物将被适当地分开。然而,你仍然希望在路径名称上有意描述性。
有关mount方法的更多详细信息,请参阅https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/Base.html#method-i-mount。
添加FilePond引用
这一步是特定于我们gem的,但适用于你创建的任何依赖于上游(通常是JavaScript)依赖的gem。
理想情况下,我们可以尽量减少在gem中包含任何FilePond源代码,只需告诉最终用户自己添加。如果可能的话,我们可以避免添加任何代码。
对于我们的特定情况,我们将使用importmap,它确实允许我们引用特定版本的FilePond而不将其添加到我们gem的源代码中。不幸的是,importmap-rails不支持引用CSS文件。
为了让我们gem的用户更容易,我们将这些CSS文件添加到我们的资产中,以便更容易包含。具体来说,我们将filepond.css和filepond.min.css添加到app/assets/stylesheets文件夹中。
我们还在README.md中添加说明,让用户知道他们需要这样做。
接下来,我们还需要确保上游FilePond JavaScript库被加载。类似于我们的原始代码库,我们将importmap.rb文件添加到config目录并像这样修改它:
# config/importmap.rb
pin 'filepond', to: 'https://ga.jspm.io/npm:filepond@4.30.4/dist/filepond.js', preload: true
这将把我们gem的版本”锁定”到FilePond v4.30.4。每当FilePond更新时,我们需要提升这个以支持新版本。
与CSS资产类似,我们在README.md中添加注释,指导用户将javascript_importmap_tags添加到他们的application.html.erb布局文件中,以便importmap.rb中的定义在渲染时加载。
添加我们的JavaScript代码
最后,我们需要添加我们的JavaScript代码来将所有内容链接在一起。在我们的原始代码库中,我们将所有这些代码放在这里:https://github.com/Code-With-Rails/filepond-demo/blob/main/app/javascript/application.js。
虽然上面的代码可能适用于我们的应用程序,但当我们创建一个库时就不合适了。
为此,我们将支持使用ESM模块。我们修改JavaScript代码以导出函数,以便gem的用户可以导入它们:
// 在宿主应用程序的JavaScript文件中...
import { FilePondRails, FilePond } from 'filepond-rails'
window.FilePond = FilePond
window.FilePondRails = FilePondRails
const input = document.querySelector('.filepond')
FilePondRails.create(input)
我们这里的目标是让用户尽可能容易地集成我们的库。记住,我们的目标开发者受众可能是使用原生JavaScript或Stimulus的Rails开发者。
注意:回想一下之前我提到作为gem作者你需要考虑你的库将需要哪些软件依赖。
对于
filepond-rails,我已声明想要使用此库的开发者应该使用Rails 7+并使用importmap-rails。因为importmap-rails依赖于使用ESM模块,这就是filepond-rails在JavaScript加载方面将支持的内容。
使用Dummy应用进行测试
此时,你可能想知道:这一切都能工作吗?
这是一个好问题,通过引导一个Rails应用并按照我们的设置说明进行操作,很容易回答。在我们gem的源代码中,有一个”Dummy”应用程序,我们可以用它来做这件事。
对于成品,你可以在这里查看源代码:https://github.com/Code-With-Rails/filepond-rails/tree/main/test/dummy。
在这个应用中,我们创建一个控制器、视图和JavaScript来实例化我们gem的自定义JavaScript。
一旦我们完成所有这些,我们可以用以下命令启动服务器:
bin/rails server
你应该能够访问http://localhost:3000来查看你的工作。
注意:如果你想自己尝试,请查看https://github.com/Code-With-Rails/filepond-rails的filepond-rails。
**问题:**不使用Dummy应用,我如何在同时开发的宿主应用程序上测试我的gem?
**回答:**要做到这一点,你可以在本地引用你的gem。
# 宿主应用程序的Gemfile gem 'filepond-rails', path: '/path/to/filepond-rails-local'如果你更新了gem源代码中的代码,记得运行
bundle install和bundle update。
添加测试
为了确保我们的gem工作并且未来的更新不会破坏功能,我们需要添加一些测试。
对于filepond-rails,我们将使用minitest。但是,如果你愿意,你可以选择任何框架(例如rspec)。
因为我们的应用程序由两部分组成(我们的IngressController和JavaScript加载器代码),这些是我们将测试的内容。
控制器测试相当简单,是标准的Rails控制器测试:https://github.com/Code-With-Rails/filepond-rails/blob/main/test/controllers/filepond/rails/ingress_controller_test.rb。
接下来,我们添加系统测试。系统测试,根据Rails Guide:
系统测试允许你测试用户与应用程序的交互,在真实或无头浏览器中运行测试。系统测试在底层使用Capybara。
对于像filepond-rails这样增强用户界面的gem,我们希望确保我们的JavaScript源代码正确加载和运行。
要创建系统测试,我们输入:
bin/rails generate system_test uploads
这将生成相应的test/system/filepond/rails/uploads_test.rb。此测试通过在命令行中输入bin/rails app:test:system来运行(因为它不会默认与其余测试一起运行),将在无头浏览器中测试内容。
发布我们的gem
终于,我们到达了可以发布gem的阶段。在此之前,让我们创建一个CHANGELOG文件。更改日志应包含你所做的所有主要、次要和破坏性更改。我通常也会标注发布日期。
接下来,我建议你像对任何其他软件项目一样标记版本发布。
示例:
git tag v1.0.0
一旦你将其推送到GitHub,创建一个发布并附上好的描述。
要将你的gem发布到RubyGems.org以便其他用户可以访问,请按照https://guides.rubygems.org/publishing/的说明操作。
你首先需要像这样构建你的gem:
gem build filepond-rails
这将创建一个像这样的文件:filepond-rails-1.0.0.gem。
一旦你有了它,你可以这样做:
gem publish filepond-rails-1.0.0.gem
注意:你需要在RubyGems.org上有一个账户,并需要验证你的gem CLI才能公开推送更改。
文档
filepond-rails是一个简单的小gem,因此不需要完整的项目网站。对于具有大型公共API的更复杂的gem,应该有适当的文档。
提示:查看https://rubydoc.info/并在那里添加你的gem。这是一个有用的工具,可以帮助从代码注释生成文档并使其可公开访问给开发者用户。
改进想法
与任何项目一样,总有改进的空间。这篇博客文章只是触及表面,提供了创建一个你和其他人可以在多个Rails应用程序中使用的gem项目的最低步骤。
可以做的其他事情(无特定顺序):
- 使用Appraisal针对多个Rails版本进行测试
- 添加GitHub Action CI(这里没有涉及,但你可以查看
filepond-rails的仓库看看我们是怎么做的) - 添加发布到RubyGems.org的发布管道(这样我们可以完全跳过手动发布gem)
结论
为其他人使用创建一个开源gem可以是一种令人满意的体验,无论是技术上还是专业上。
在专业层面上,这是回馈社区并帮助可能面临与你相同情况的其他人的好方法。
从技术上讲,库创建还需要你仔细考虑你想解决的问题,并仔细思考你的解决方案,使其最小化问题并且不会给最终用户带来问题。出于这些原因,我通常喜欢保持gem库体积小,具有狭窄的外科手术式焦点。
我希望你发现这个教程有用。再次说明,filepond-rails gem的源代码可以在https://github.com/Code-With-Rails/filepond-rails找到。对于gem本身,请查看https://rubygems.org/gems/filepond-rails。