使用 Sorbet 类型为 Ruby 带来更多甜蜜 🍦
你有没有想过在你的 Ruby 代码中添加类型检查?想从一只橡皮鸭变成一只全副武装的鸭子?(双关语)没有?那么,请允许我使用Stripe提供的 gem sorbet向你介绍类型检查。
目录
我们为什么需要类型?
如果你过去几年一直在 Ruby 社区,那么你可能并不是类型的超级粉丝,或者你从未想过这个概念,这完全没问题。我自己很喜欢 Ruby 的动态和元编程特性,说实话,在撰写本文时,我们在类型检查和推断方面还没有达到OCaml的水平,但 sorbet 的类型仍然带来了一些好处:
- 1. 编写更少的测试:好吧,对于我们 TDD 爱好者来说这是一个明智的话题,但编写更少的测试并不意味着完全放弃测试,而是通过测试我们的业务逻辑和应用程序的重要部分来编写真正重要的测试,而不是通过检查函数是否
a
接收数字。 - 2. 更自信地提交代码:如果你的 Ruby 开发人员在生产环境或 PR 流程中(如果你的团队喜欢周末加班)没有提交代码,那就扔石头吧
'mymethod': undefined method '-' for "test":String (NoMethodError)
。为你的代码设计证明的整个想法就是为了解决这种情况,因为现在我们有了一个类型检查步骤,可以确保在大多数情况下不会出现这种类型的错误。 - 3. 更加信任您的环境:Ruby 社区多年来学会了依赖“愚蠢的代码分析工具”,例如Ctags、Grep和find,而不是智能代码分析工具(说实话,Solargraph 并不是那么好),但通过为我们的代码带来类型证明,它可以创建更好的工具来分析我们的代码并提供“转到定义”、“完成”、“悬停”等(又名 sorbet LSP)。
这篇文章仅代表个人观点,但我坚信拥抱类型系统是现代软件开发的正确选择。或许,未来我们珍爱的 Ruby 代码也会像外界(Haskell、OCaml、Elm 等)一样,秉承“能编译就行”的原则。
sorbet 旨在如何介绍类型?
首先让我们介绍一下该工具:Sorbet是Stripe开发的一个 gem ,旨在通过利用“渐进式类型”理念为 Ruby 生态系统带来类型符号语法和类型检查支持,它还通过tapioca gem 从 YARD 注释提供类型生成,从而可以与已经构建的 Ruby 代码库一起成长。
好的,但是渐进打字是什么?
渐进类型化是一个术语,它定义了一种与 的概念共存的类型系统untyped
,其中无类型化是指编译器需要在某种程度上忽略的代码部分(例如any
TypeScript 和mixed
PHP)。这在基于动态语言开发类型系统时非常必要,因为不可能为了进行类型检查而丢弃所有先前编写的代码。
Sorbet 还超越了这种untyped
方法,允许开发人员为每个文件启用类型检查。这样,您就可以完全控制要进行类型检查的区域以及所需的严格程度,如下所示:
- 忽略:通过在文件中添加注释
# typed: ignore
,您告诉 Sorbet 完全忽略该文件及其可能的错误。显然,这完全不推荐,对吧? - false:这是 sorbet 假定的默认状态,即使您的文件没有任何注释,它也只报告语法错误(例如当您输入
deff
而不是 时def
)。 - true:乐趣就从这里开始,添加
# typed: true
到您的文件中可以启用完整的类型检查,但假定T.untyped
所有代码都没有sig
定义注释。 - strict:严格模式会禁用
untyped
for 创建的代码,并强制整个文件使用类型符号。一般建议是:“如果你的文件可以使用 typed: strict ,那就让它保持原样。” - strong:之前的严格模式允许你明确指出某个函数是
untyped
,但在强模式下你甚至无法做到这一点,所有函数都必须具有正确的类型。这种模式很适合实验,但我不会在生产代码中使用它。
那么 ruby 3 怎么样?
最近,我们看到 Matz 谈论新的RBS解决方案,该解决方案应该为 Ruby 带来类型检查,虽然这非常酷,但它相当新,而且在我看来这种方法存在一些问题,例如:
- 1. 缺乏 LSP:由于这种新的类型检查解决方案相当新(在撰写本文时),我们目前还没有通过 LSP 提供良好的编辑器支持。像steep这样的工具将来可能会解决这个问题,但目前它还不是一个可靠的解决方案。另一方面,Sorbet 已经在市场上存在多年,并且已经提供了许多代码智能工具,您可以在这篇博文中了解更多信息。
- 2. 不支持内联类型:Ruby 3 的类型系统强制要求你在单独的文件中定义类型证明
rbs
,这解决了部分类和函数的类型问题,但却使得无法对声明的变量进行类型声明(你可能希望这样做)。Sorbet 通过T.let
函数实现了这样的功能。 - 3. 缺少类型生成工具:与提供
tapioca
gem 从 YARD 文档生成类型的 sorbet 解决方案不同,允许逐步输入代码库,而 Rubyrbs
格式并未提供任何官方解决方案,这让早期采用者的生活变得相当困难。
编辑器支持和配置
既然我已经(希望)向各位读者解释了我支持 sorbet 的理由,那么让我们来看看更实际的方面,比如在代码库中使用 sorbet 时的编辑器支持。首先要理解的是,用于类型检查的 gem 可以通过传递--lsp
flag 来托管 LSP 服务器。
- Web 人员的标准编辑器(不幸的是);VS Code 通过扩展获得了官方支持,并且在这篇官方博客文章上有更详细的介绍。
- 像 RubyMine 这样的重量级 IDE 也通过扩展为 sorbet 提供官方支持。
- 也是我个人最喜爱的选择;(neo)vim 通过使用任何 LSP 插件提供支持,主要插件也提供官方支持,例如nvim lspconfig或vim ale。
免责声明:我没有找到对 TextMate 或 sublime 的任何支持,抱歉 :(
动手实践!如何使用 sorbet 创建示例 API
闲话少叙,现在该深入代码了。让我们用 Sinatra 编写一个简单的 API,来演示使用 Sorbet 实现类型正确性的潜在优势和挑战。
信息:我将使用上一篇文章中介绍的架构的简化版本,如果您想了解更多背景信息,请查看:https://dev.to/cherryramatis/creating-a-sinatra-api-with-system-wide-dependency-injection-using-dry-system-10mp
创建新项目并初始化 sorbet
由于这部分在我的所有文章中都是众所周知的,让我们使用 Sinatra 和 Zeitwerk 快速运行一个基本的 Ruby 项目设置来自动获取代码:
首先,让我们创建一个具有必要依赖项的新 Ruby 项目,如下所示:
免责声明:sorbet 依赖项将在专门的部分中
$ mkdir myproject && cd myproject
$ bundle init
$ bundle add zeitwerk sinatra puma pry pry-reload
我们已经安装了基本依赖项,现在是时候配置Zeitwerk来处理我们文件的自动依赖了。为此,请在 创建一个文件config/application.rb
并填充以下内容:
# frozen_string_literal: true
require 'zeitwerk'
require 'pathname'
root = Pathname('.')
loader = Zeitwerk::Loader.new
loader.push_dir(root.join('lib').realpath)
loader.push_dir(root.join('config').realpath)
loader.setup
我们将使用Pry为项目添加基本的 REPL(读取-求值-打印循环)支持。为此,请在 创建一个文件bin/console
并包含以下内容:
#!/usr/bin/env ruby
require 'sorbet-runtime'
require_relative '../config/application'
require 'pry-reload'
require 'pry'
Pry.start
免责声明:这里包含 sorbet-runtime 使我们能够使用我们将进一步看到的所有类型符号正确运行我们的代码。
提示:不要忘记运行 chmod +x ./bin/console 以使您的脚本可执行。
在为任何 ruby 项目配置完必要的内容后,让我们开始处理 sorbet 环境,首先打开Gemfile
文件并添加以下内容:
gem 'sorbet-runtime'
group :development do
gem 'sorbet'
gem 'tapioca', require: false
end
在此配置中,我们将sorbet
和都放在tapioca
开发组中。这是因为在生产环境中,我们将排除类型检查(因为它应该只在本地机器上运行)。但是,sorbet-runtime
包含在所有环境中,以便于使用特定类型符号语法运行代码。
为了继续设置,让我们使用以下命令初始化 sorbet 环境:
$ bundle exec tapioca init
免责声明:不要将 sorbet/ 文件夹添加到您的 .gitignore 文件中,保持版本控制很重要,因为您可以使用
*.rbi
语法编辑这些文件或创建新文件。
要为已安装的 gem 生成所需的类型文件,请在 shell 中运行以下命令:
$ bundle exec tapioca gems
要测试您的安装并查看它是否正常工作,您可以在 shell 中运行以下命令:
$ bundle exec srb tc
您应该会看到在stdoutNo errors! Great job.
上显示的消息,这表明我们的设置工作到目前为止已成功完成🍒。
定义我们的主要路由器文件
由于我们要构建一个基本的 API,因此让我们定义一个位于的路由器类,config/router.rb
其内容如下:
请注意,我们正在使用# typed: true
注释,因此我们应该从 LSP 中获取所有类型的优点!
# frozen_string_literal: true
# typed: true
require 'json'
require 'sinatra/base'
class Router < Sinatra::Base
get '/' do
JSON.dump({ message: 'Hello World' })
end
end
定义好路由后,我们需要一个基本的服务器,对吧?这时 Puma 就派上用场了!为了初始化 Puma 服务器,我们将config.ru
在项目根目录下创建一个包含以下内容的文件:
# frozen_string_literal: true
require 'sorbet-runtime'
require_relative 'config/application'
Router.run!
创建服务
你可能会想:“这很好,但是类型检查在哪里呢?” 别担心!我已经帮你搞定了。让我们创建一个包含lib/services/hello_world_service.rb
以下内容的新文件:
# frozen_string_literal: true
# typed: true
module Services
class HelloWorldService
extend T::Sig
sig { params(name: T.nilable(String), lang: T.nilable(String)).returns(T::Hash[Symbol, String]) }
def call(name, lang)
predicate = case lang
when 'pt'
'Ola'
else
'Hello'
end
return { message: "#{predicate} anon" } if name.nil?
{ message: "#{predicate} #{name}" }
end
end
end
我们现在就开始讨论吧!这个语法是不是很棒?我个人觉得它很有吸引力。
为了启用该sig
语法,我们首先在类中扩展T::Sig
模块。如您所见,在使用sig
语法描述方法的类型正确性时,我们有几种可用的选项,如下所示:
sig { params().returns() }
sig { returns() }
你知道最棒的部分是什么吗?这 100% 纯正的 Ruby 语法!这意味着你不需要任何特殊的语法高亮器或转译器——只需要经典而优美的 Ruby 代码。
除了语言提供的内置类型(例如 String 和 Integer)之外,T
标识符下还有一系列可用的构造来表达更复杂的类型。以下是我认为需要注意的一些关键点:
- T.nilable:这告诉类型检查器一个值可以是,
nil
并且会进一步导致错误,您需要使用该方法添加保护nil?
或通过说服检查器信任您T.must
。- T.must:如果您不能或不想添加 if 子句,您可以使用
T.must
如下语法强制编译器某些内容不为 nil:,T.must(something_not_nilable)
这更像是一个“相信我”的声明。
- T.must:如果您不能或不想添加 if 子句,您可以使用
- T::Hash:这允许我们通过提供键和值类型来表达哈希的类型,如下所示:
T::Hash[String, Integer]
- T::Array:与哈希相同,但用于数组,可以像这样使用:
T::Array[String]
。 - T.let:以我个人拙见,这就是 Sorbet 的闪光点,因为它支持对变量进行内联输入,例如:
foo = T.let(nil, T.nilable(String))
。
回到我们的代码,由于我们在方法中接收参数name
和lang
,因此我们需要修改路由器config/router.rb
:
# frozen_string_literal: true
# typed: true
require 'json'
require 'sinatra/base'
class Router < Sinatra::Base
get '/:name/:lang' do
return JSON.dump({ error: 'Provide correct params' }) if params.nil?
response = Services::HelloWorldService.new.call(T.must(params)['name'], T.must(params)['lang'])
JSON.dump(response)
end
end
bundle exec srb tc
一切都很好,对吧?但是如果你现在运行该命令,将会得到以下错误:
config/router.rb:9: Method params does not exist on T.class_of(Router) https://srb.help/7003
由于params
是 Sinatra gem 提供的一个特殊变量,所以 sorbet 无法推断出任何类型并报错。不过不用担心!接下来介绍一个在使用 Sorbet 时会经常用到的很酷的概念,叫做shims
。
Shims 是rbi
我们声明的文件,用于提供 sorbet 无法自行推断的类型。考虑到这一点,让我们创建一个位于 的文件,sorbet/rbi/shims/sinatra_base.rbi
其内容如下:
这就是为什么您需要对 sorbet/ 文件夹进行版本控制!
# typed: true
module Sinatra
class Base
extend T::Sig
sig { returns(T.nilable(T::Hash[String, String])) }
def self.params; end
end
end
现在我们将声明params
为可能为空的哈希,其键和值都是字符串,如下所示:
{"name" => "Cherry Ramatis", "lang" => "pt"}
您可以观察到,在路由器文件中,我们使用T.must
来告诉检查器,尽管有可能params
返回 nil,但在运行路由时不可能有任何 nil 值。
但是为什么我们没有添加一些return if params.nil?
你可能正在想的东西呢?在这个特殊情况下,这是不可能的,因为params
它是一个方法而不是一个变量。当然,你可以调整垫片类型,甚至可以创建一个接收的变量params
,但在这种情况下,我更喜欢直接使用T.must
子句!
最后但同样重要的是,我们拥有极其复杂的API:
结论
希望这篇全面的 sorbet 指南能帮你有所收获。我的主要目标是介绍一下这个充满新想法的生态系统,并希望鼓励更多人尝试,共同创造 Ruby 类型检查的美好未来!
您可以从此文档sig
中获取有关 Sorbet语法在类型表示法方面的更高级可能性的更多信息。愿原力与你同在!