使用 dry-system 和 rom-rb 创建具有系统范围依赖注入的 Sinatra API

2025-05-25

使用 dry-system 和 rom-rb 创建具有系统范围依赖注入的 Sinatra API

Sinatra 通常被视为简单 API 的工具,但它也可以用于管理大型应用程序。dry-rb 库可以帮助您为应用程序创建具有系统范围依赖注入的模块化架构。

目录

介绍

如今,Ruby 初学者在开发应用程序时,通常会考虑两种方案:如果想要一个简单的单文件 API,那就用Sinatra ;如果只想实现其他功能,那就用Ruby on Rails。那么,在本文中,我将提供一种方法来管理一个大型应用程序,使用 Sinatra 作为 HTTP 库,并使用 dry-rb 库作为模块化架构的粘合剂。

注意:本文生成的所有代码都可以在以下位置找到: https: //github.com/cherryramatisdev/api-with-dry-ruby

当我们的应用程序开始增长时我们该怎么办?

当一个只有一个 Ruby 文件的应用程序开始变得越来越依赖时,我们该怎么办?就我个人而言,答案是依赖注入。基本上,我会开始思考如何管理所有这些新库的配置,并在路由中快速使用它们,这样以后将路由拆分成服务和控制器类就变得轻而易举了。

好的,但是我们该怎么做呢?

对于依赖注入的基本理解可以从以下角度来看:

考虑这个“服务”类:

class SomeService
  def something_important
    # doing something important here

    'information'
  end
end
Enter fullscreen mode Exit fullscreen mode

如果我们想要注入这个服务,我们可以简单地在构造函数中实例化它,例如在控制器上:

require_relative 'services/some_services'

class SomeController
  def initialize
    @service = SomeService.new
  end

  def index
    response = @service.something_important

    {result: response}.to_json
  end
end
Enter fullscreen mode Exit fullscreen mode

但是这种方法有什么问题呢?嗯,对于小规模的应用程序来说,这并不会带来太多的复杂性,而且保持所有组件的隔离和可用性也相当简单,但是对于中型到大型的应用程序来说,我们会引入一些麻烦,例如:

  1. 并非所有提供商都具有简单的设置::某些提供商(例如 ORM)需要更多配置,这可能难以维护并通过应用程序提供。

  2. 一些提供商依赖于另一个提供商::当您想要一个提供商用于数据库连接,另一个提供商用于存储库时,手动管理会非常困难,而且这种情况经常发生。

  3. Require hell :: 在 Ruby 上,我们没有在每个文件上导入所有库和内部代码的习惯;Ruby on Rails 等框架为具有业务逻辑的文件提供自动需要功能,当您手动滚动应用程序时,如果没有此功能就很难开发。

如何解决这些问题?干式系统来拯救我们

我们将假设一个简单的 Sinatra 应用程序,并通过添加 dry-system 来管理我们的依赖关系;稍后,我们甚至会使用名为 rom-rb 的 gem 添加一个持久层,以增加更现实的 API 示例的功能。

一个简单的 Sinatra 应用程序

Sinatra 是一个轻量级库,设置起来非常简单,但让我们从一个更结构化的项目开始,好吗?

免责声明:本部分假设您具备关于 ruby​​ 语言和 sinatra 库的基本知识。

启动捆绑项目

mkdir myproject && cd myproject && bundle init
Enter fullscreen mode Exit fullscreen mode

添加我们的宝石

bundle add sinatra puma
Enter fullscreen mode Exit fullscreen mode

创建一个路由器类来封装我们的执行

位于config/router.rb

require 'sinatra/base'

class Router < Sinatra::Base
  get '/' do
    {message: 'Hello world'}.to_json
  end
end
Enter fullscreen mode Exit fullscreen mode
  1. 添加一个config.ru作为我们应用程序的入口点

位于config.ru项目根目录

require_relative 'config/router'

Router.run!
Enter fullscreen mode Exit fullscreen mode

通过此初始设置,我们应该能够运行应用程序bundle exec puma并看到 JSON 作为响应。

改进我们的 Sinatra 应用程序

为了让我们更容易地看到依赖注入的好处,让我们通过创建两个简单的抽象来为这个简单的路由添加一些结构:控制器和服务。

首先,我们将创建一个位于的服务,lib/service/user.rb其内容如下:

module Services
  class User
    def index
      'teste'
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

然后让我们创建一个位于的示例控制器,lib/controllers/user.rb其内容如下:

require_relative 'lib/services/user'

module Controllers
  class User
    def initialize
      @service = Services::User.new
    end

    def index
      {message: @service.index}.to_json
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

如您所见,我们已经按照旧方法实例化了服务,因此我们可以通过添加来进行比较dry-system

最后,只需更新config/router.rb文件的内容:

require 'sinatra/base'
require_relative 'lib/controllers/user'

class Router < Sinatra::Base
  get '/' do
    Controllers::User.new.index
  end
end
Enter fullscreen mode Exit fullscreen mode

很简单吧?现在一切都应该正常了,但我们不会就此止步,所以让我们开始整合dry-system它,看看它的好处。

添加 dry system 和 dry auto inject gem 作为我们的依赖注入层

添加我们的宝石

bundle add dry-system dry-auto_inject zeitwerk
Enter fullscreen mode Exit fullscreen mode

使我们的应用程序 REPL 工作

REPL(读取-求值-打印循环)对于 Ruby 开发者来说是一个非常重要的工具。Rails 和 Hanami 框架都提供了 REPL,所以我们将为应用程序设置一个简单的 REPL。这将使我们能够进一步集成依赖注入层,从而使我们的代码更加模块化,更易于测试。

为此,我们将创建一个名为 config/boot.rb 的文件并添加以下代码:

ENV['APP_ENV'] ||= 'development'

require 'bundler'
Bundler.setup(:default, ENV.fetch('APP_ENV', nil))
Enter fullscreen mode Exit fullscreen mode

之后创建一个脚本文件,bin/console内容如下:

#!/usr/bin/env ruby
require 'irb'

IRB.start
Enter fullscreen mode Exit fullscreen mode

为了使其可执行,您可以运行chmod +x ./bin/console

现在我们应该有一个适用于该应用程序的 REPL!

创建我们的主要容器

该容器将用于注册我们应用程序的所有其他组件

在下面创建一个文件,config/application.rb内容如下:

require 'dry/system'

class Application < Dry::System::Container
  configure do |config|
    config.root = Pathname('.')

    config.component_dirs.loader = Dry::System::Loader::Autoloading

    config.component_dirs.add 'lib'
    config.component_dirs.add 'config'
  end
end

loader = Zeitwerk::Loader.new
loader.push_dir(Application.config.root.join('lib').realpath)
loader.push_dir(Application.config.root.join('config').realpath)
loader.setup
Enter fullscreen mode Exit fullscreen mode

通过这段代码,您可以看到我们已经解决了其中一个问题;该component_dirs.add方法和 Zeitwerk 实例将自动需要libconfig文件夹中的所有代码。

注意:zeitwerk gem 正在为我们进行延迟加载。

让我们将其包含在我们的入口点中,以使其立即发挥作用。

config.ru和上bin/console我们将添加以下内容:

require_relative 'config/application'

Application.finalize!
Enter fullscreen mode Exit fullscreen mode

finalize!方法使Application实例变量可用于整个应用程序,并延迟加载libconfig文件夹下的文件。

require_relative提示:您可以(并且建议)从控制器和路由器文件中删除

现在您可以通过在 REPL 上bin/console键入来运行和检查应用程序实例。Application

添加示例服务作为提供者

现在我们有了主容器,接下来只需要向其注册提供者,如下所示:

创建一个位于的文件,config/providers/services.rb其内容如下:

Application.register_provider(:services) do
  start do
    register('services.user', Services::User.new)
  end
end
Enter fullscreen mode Exit fullscreen mode

创建此提供程序后,我们会将其加载到我们的入口点文件中;这些是我们唯一需要文件的地方。

config.ru

require_relative 'config/providers/services'
Enter fullscreen mode Exit fullscreen mode

还有bin/console

require_relative '../config/providers/services'
Enter fullscreen mode Exit fullscreen mode

享受工作带来的益处

回到我们的控制器类,我们可以像这样重写它:

module Controllers
  class User
    def initialize
      @service = Application['services.user']
    end

    def index
      {message: @service.index}.to_json
    end
  end
Enter fullscreen mode Exit fullscreen mode

看看控制器类是如何对它从哪个类获取数据一无所知的Application['services.user']?这太酷了,因为如果你想彻底改变你的服务,你只需在提供程序文件上修改类的实例即可。

这个最初的目标对我们来说已经实现了,对吧?但我们会继续前行。

使用 ROM 和我们的模块化架构添加数据库连接

现在我们已经对如何dry-system模块化我们的应用程序有了基本的了解,让我们利用这些知识来添加一个数据库层rom-rb

添加我们的宝石

bundle add rom rom-repository rom-sql pg
Enter fullscreen mode Exit fullscreen mode

将数据库连接注册为我们系统的提供者

由于我们已经使用了dry-system这一点,因此让我们通过添加数据库连接作为提供程序来使用它:

创建位于的文件,config/providers/db.rb内容如下

免责声明:假设您正在运行 PostgreSQL 数据库。

Application.register_provider(:db) do
  prepare do
    require 'rom'
    require 'rom-sql'
  end

  start do
    connection = Sequel.connect('postgres://postgres:postgres@localhost:5432/example_database', extensions: %i[pg_timestamptz])
    register('db.connection', connection)
    register('db.config', ROM::Configuration.new(:sql, connection))
  end
end
Enter fullscreen mode Exit fullscreen mode

如您所见,该register_provider方法提供了一个简单的 DSL,我们可以使用它来隔离整个设置,方法是要求正确的库prepare,然后在上实例化或注册对象start

添加对迁移命令的支持

现在我们已经完成了基本连接,让我们Rakefile在项目的根目录下创建一个包含以下内容的连接:

require 'rom-sql'
require 'rom/sql/rake_task'

require_relative 'config/boot'
require_relative 'config/application'
require_relative 'config/providers/db'

namespace :db do
  task :setup do
    Application.start(:db)
    config = Application['db.config']
    config.gateways[:default].use_logger(Logger.new($stdout))
  end
end
Enter fullscreen mode Exit fullscreen mode

如您所见,我们可以用start方法来替代finalize!注入所有提供程序的方法。这样,我们只需通过:db符号启用数据库层即可。这使我们能够在任务上进行注入:setup

现在我们应该能够运行以下命令:

rake "db:create_migration[create_users]"
Enter fullscreen mode Exit fullscreen mode

这应该创建一个位于的文件db/migrate/3128932189_create_users.rb,在这个文件上,我们可以补充以下 DSL 来为我们的应用程序创建一个示例表:

ROM::SQL.migration do
  change do
    create_table :users do
      primary_key :id
      column :name, String
      column :email, String
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

最后,通过运行以下命令,我们可以将此迁移保留在 Postgres 数据库上:

rake db:migrate
Enter fullscreen mode Exit fullscreen mode

定义我们的关系和存储库

rom-rbgem 中,我们将主要类定义为关系存储库。关系模仿 Postgres 表的结构,而存储库定义我们在该关系上的操作。

首先,我们将定义一个关系来表示我们创建的新表。为此,我们将创建一个名为的文件lib/relations/users.rb并添加以下代码:

module Relations
  class Users < ROM::Relation[:sql]
    schema(:users) do
      attribute :id, Types::Integer
      attribute :name, Types::String
      attribute :email, Types::String

      primary_key :id
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

在这里,我们使用ROM::Relation类提供的简单 DSL 来模拟我们的迁移,并为每个属性提供正确的类型。

现在对于存储库,我们可以创建一个lib/repos/user.rb包含以下内容的文件:

require 'rom-repository'

module Repos
  class User < ROM::Repository[:users]
    commands :create

    # @param limit Integer
    def all(limit = 10)
      users.limit(limit).to_a
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

ROM 上的代码库提供了一些常见操作的示例命令,例如
创建、更新和删除记录。但是,对于更复杂的查询,我们需要编写自己的方法。在本例中,我提供了一个简单的all方法,用于返回所有用户(数量有限)。

通过代码库提供我们的代码

由于我们为应用程序定义了两个新组件,因此我们将在系统上创建两个新的提供程序。

config/providers/persistence.rb首先,让我们创建一个具有以下内容的提供程序:

Application.register_provider(:persistence) do
  start do
    target.start :db

    config = target['db.config']

    config.register_relation(Relations::Users)

    register('container', ROM.container(config))
  end
end
Enter fullscreen mode Exit fullscreen mode

与我们类似,我们在实例化关系类时Rakefile使用该start方法使提供程序可用。db

然后让我们创建另一个提供程序,config/providers/repos.rb其内容如下:

Application.register_provider(:repos) do
  start do
    target.start :persistence

    register('repos.user', Repos::User.new(target['container']))
  end
end
Enter fullscreen mode Exit fullscreen mode

看看我们start之前定义的持久化提供程序是怎么回事?我们不需要启动该db提供程序,因为 dry-system 会自动跳转到持久化提供程序并从那里启动,所以我们可以拥有任意数量的相互依赖的提供程序。

由于我们添加了新的提供商,我们将照常更新我们的入口点文件:

config.ru

require_relative 'config/providers/persistence'
require_relative 'config/providers/repos'
Enter fullscreen mode Exit fullscreen mode

并且位于bin/console

require_relative '../config/providers/persistence'
require_relative '../config/providers/repos'
Enter fullscreen mode Exit fullscreen mode

重构时间到了,好吗?

现在我们已经定义了所需的提供程序,接下来只需在我们想要的层上使用它们;这一层将成为我们的服务类。

在 的服务类上lib/services/user.rb,我们将重写以使用存储库:

module Services
  class User
    def initialize
      @repo = Application['repos.user']
    end

    def list_all
      users = @repo.all

      users.map do |user|
        { id: user.id, name: user.name, email: user.email }
      end.to_json
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

重构完成!很简单,对吧?我们的路由现在应该使用数据库来提供用户列表。

结论

我希望这篇文章对任何读到它的人都能有所帮助。我试图演示如何轻松地解耦应用程序的各个部分并进行管理,即使每个部分都需要复杂的设置。

此外,我随时乐意解答您的任何疑问,或者只是聊聊一些 Ruby 的酷炫知识。愿原力与你同在!

文章来源:https://dev.to/cherryramatis/creating-a-sinatra-api-with-system-wide-dependency-injection-using-dry-system-10mp
PREV
结束战争还是继续战争?让我们将函数式编程引入 OOP 代码库
NEXT
补充异常 - 引入 Ruby 中的 Monad 进行错误处理