发布于 2026-01-06 4 阅读
0

Ruby 中的高效服务对象 让我们构建一个服务对象 应用业务逻辑 服务组合 用于重复性任务的命令

Ruby 中高效的服务对象

让我们构建一个服务对象

应用业务逻辑

服务组成

重复性任务的命令

作为一名最近转岗到 Ruby on Rails 公司的 Java 开发人员,当我发现直接在控制器中使用模型是一种常见的做法时,我感到有些迷茫。

我一直遵循领域驱动设计的良好实践,并将业务逻辑封装在称为服务对象的特殊类中,因此在 Java(使用Spring)中,控制器看起来像这样:

@Controller
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public ResponseEntity<Iterable<User>> getAllUsers() {
        return ResponseEntity.ok(userService.getUsers());
    }
}
Enter fullscreen mode Exit fullscreen mode

抛开冗长之处不谈,这段代码简洁明了,关注点分离做得很好。实际用于获取用户列表的业务逻辑已委托给UserService实现层,并且可以随时替换。

但是,在 Rails 中,我们可以这样编写这个控制器:

class Api::UserController < ApplicationController
    def index
        @users = User.all
        render json: @users
    end
end
Enter fullscreen mode Exit fullscreen mode

是的,这确实比 Java 示例更简洁,也更清晰,但它存在一个重大缺陷。User它使用的是 ActiveRecord 模型,这样做会将控制器与持久层紧密耦合,从而破坏了领域驱动设计 (DDD) 的一个关键原则。此外,如果我们想在请求中添加授权检查,例如仅根据当前用户的角色返回用户子集,则必须重构控制器,并使其负责一些与表示层逻辑无关的功能。而使用服务对象,我们可以在对外部透明的情况下添加更多逻辑。

让我们构建一个服务对象

在 Java 中,这很简单。它是一个单例类,由我们的 IoC 容器(在本例中是 Spring DI)注入到其他类中。
在 Ruby 中,尤其是在 Rails 中,情况略有不同,因为我们无法在控制器构造函数中注入任何东西。不过,我们可以借鉴另一种编程语言Elixir的做法。Elixir
是一种函数式语言,它没有类也没有对象,只有函数和结构体。函数被分组到模块中,并且没有副作用,这对于确保代码的不可变性和稳定性至关重要。
由于 Ruby 也支持模块,我们可以利用它们将服务对象实现为无状态的方法集合。

我们的 UserService 服务可能如下所示:

module UserService
    class << self
        def all_users
            User.all
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

然后就可以这样使用了:

class Api::UserController < ApplicationController
    def index
        @users = UserService.all_users
        render json: @users
    end
end
Enter fullscreen mode Exit fullscreen mode

这听起来似乎不太明智,对吧?我们只是把调用移到User.all了另一个类里。没错,但现在,随着应用程序的增长,只要保​​持 API 的稳定性,我们就可以在不破坏其他代码或重构代码的情况下添加更多逻辑。
在继续之前,我先做一个小小的改动。由于我们可能需要在每次调用时向服务注入一些数据,我们将为方法定义一个名为 ` ctxcontext` 的第一个参数,它将包含当前执行上下文。诸如当前用户之类的信息都将包含在这里。

module UserService
    class << self
        def all_users _ctx # we'll ignore it for now
            User.all
        end
    end
end
Enter fullscreen mode Exit fullscreen mode
class Api::UserController < ApplicationController
    def index
        @users = UserService.all_users { current_user: current_user }
        render json: @users
    end
end
Enter fullscreen mode Exit fullscreen mode

应用业务逻辑

现在我们来构建一个更复杂的案例,并首先用用户故事来描述它。假设我们正在开发一个待办事项应用(哇,多么革命性!)。用户
故事如下:

作为普通用户,我希望能够看到下个月的所有待办事项。

RESTful HTTP 调用类似于:
GET /api/todos?from=${today}&to=${today + 1 month}

我们的财务总监将是:

class Api::TodoController < ApplicationController
    def index
        @ctx = { current_user: current_user }
        @todos = TodoService.all_todos_by_interval @ctx, permitted_params
        render json: @todos
    end

    private

    def permitted_params
        params.require(:todo).permit(:from, :to)
    end
end
Enter fullscreen mode Exit fullscreen mode

我们的服务:

module TodoService
    class << self
        def all_todos_by_interval ctx, params
            Todos.where(user: ctx[:current_user]).by_interval params
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

如您所见,我们仍然将繁重的数据库操作委托给模型(通过作用域by_interval),但服务实际上只负责当前用户的筛选。我们的控制器保持精简,模型仅用于持久化访问,业务逻辑也不会泄露到源代码的各个角落。太棒了!

服务组成

另一种非常有用的面向对象编程模式是组合模式,它能增强我们的业务层。借助组合模式,我们可以将通用逻辑分离到专用的、不透明的服务中,并从其他服务中调用这些服务。例如,我们可能希望在待办事项更新时(例如,因为待办事项已过期)向用户发送通知。我们可以将通知逻辑放在另一个服务中,并从前一个服务中调用它。

module TodoService
    class << self
        def update_todo ctx, params
            updated_todo = Todos.find ctx[:todo_id]
            updated_todo.update! params # raise exception if unable to update
            notify_expiration ctx[:current_user], updated_todo if todo.expired?
        end

        private

        def notify_expiration user, todo # put in a private method for convenience
            NotificationService.notify_of_expiration { current_user: user }, todo
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

重复性任务的命令

“四人帮”我们贡献了大量优秀的面向对象编程模式,我将借鉴他们提出的最后一个概念,大幅提升代码的隔离度。你看,我们的服务可以充当协调器而非执行器,将实际工作委托给其他类,而我们只需负责调用正确的类。这些更小的、类似“工作类”的类可以实现为命令。这样做最大的优势在于,通过使用更小的执行单元(单个命令而非复杂的服务)来增强组合性,并进一步分离关注点。现在,服务充当动作协调器,负责编排逻辑的执行方式,而实际的执行则在简单、可测试且可重用的组件中运行。

附注:我将使用simple_command这个 gem来实现命令模式,但你可以随意使用任何你想要的 gem。

让我们重构更新逻辑,使其使用命令模式:

class UpdateTodo
    prepend SimpleCommand

    def initialize todo_id, params
        @todo_id = todo_id
        @params = params
    end

    def call
        todo = Todos.find @todo_id

        # gather errors instead of throwing exception
        errors.add_multiple_errors todo.errors unless todo.update @params
        todo
    end
end

module TodoService
    class << self
        def update_todo ctx, params
            cmd = UpdateTodo.call ctx[:todo_id], params

            if cmd.success?
                todo = cmd.result
                notify_expiration ctx[:current_user], todo if todo.expired?
            end

            # let's return the command result so that the controller can
            # access the errors if any
            cmd
        end

        private

        def notify_expiration user, todo # put in a private method for convenience
            NotificationService.notify_of_expiration { current_user: user }, todo if todo.expired?
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

太棒了!现在每个类都只有一项职责(控制器接收请求并返回响应,命令执行小型任务,服务将所有功能连接起来),我们的业务逻辑无需任何支持基础设施即可轻松测试(只需模拟所有内容即可。模拟对象真好用!),而且我们的方法更小巧、更易重用。代码库确实略微大了一些,但这与 Java 项目相比仍然微不足道,而且从长远来看,这绝对值得。
此外,我们的服务不再与任何 Rails(或其他框架)特定的类耦合。例如,如果我们想要更改持久化库,或者将某个业务领域迁移到外部微服务,我们只需重构相关的命令,而无需修改我们的服务。

你的 Ruby 项目中是否使用了服务对象?你是如何实现这种模式的?你解决了哪些我的方法没有遇到的挑战?

文章来源:https://dev.to/mikamai/ effective-service-objects-in-ruby-2ga0