Develop

Управляем редиректами в одном месте

В последнее время я все больше и больше склоняюсь к тому, что хочется представлять код некоторыми абстракциями и сущностями, которые будут отвечать за логику работы системы, как отделы на предприятии или заводе, так вот, о подбной истории и захотелось написать.

Я думаю многие сталкивались с проблемой, когда при активном развитии проекта, он обрастает всевозможными пристройками, отвечающие за различные бизнес задачи. Через некоторое время, смотришь на это все со стороны и в глаза начинают бросаться дублирующиеся детали, которые вроде на своем месте, но что-то их много.

Для меня в этот раз стал наш App::BaseController.

Вступление

Что есть App::BaseController в разрабатываем приложение? Тут все просто, базовый контроллер для всех контроллеров пользовательской части. Еще недавно, был весьма компактный класс, всего 53 строки.

Окей, что не так?

Началось как всегда, клиент попросил реализовать подветверждение создаваемого пользователя после регистрации, классический набор, форма, проверочный код и все дела. Подтвержденные пользователи могут работать с системой, а те кто ее не прошел или того хуже был заблокирован, соотвественно не могут. Вроде все просто, нужно контролировать редирект и отправлять пользователя на нужные нам формы.

Открываем контроллер и видим

before_action :check_agreement, :redirect_account_verification

Хм, 2 проверки которые выполняют уже похожую логику, но по более простой схеме. И как обычно бывает - ладно, еще один фильтр с редиректом картину не изменит, ведь класс то и так небольшой, что плохого в еще в 1 методе на несколько строк кода? Окей, реализовываем логику, добавляем новый before_action все работает, тесты работают, клиент доволен, вроде все хорошо. Но осадочек остался...

Хьюстон, у нас копипаст

Спустя не очен продолжительный период, возникает необходимость, добавить еще одну проверку, на этот раз это работа 2-х факторной аутентификации с своим чемоданом различных настроек и проверок. И вот тут уже глаз начинает резать по полной программе. Опять писать новый метод с редиректом по условию? Нет, так дело не пойдет.

И так, что-то нужно с этим делать. В голову как обычно бежит идея - "а давай вынесем это все в сервисный класс, который будет сам решать, когда и куда отправить", хорошо, мысль здравая, но одновременно с этим возникает вопрос, а как вписать элегантно класс в систему, чтобы как подобает ruby коду, он читался как обычный человеческий язык, а не простой топорный redirect.

Не знаю почему, но у меня сразу в голове возникла картина - проводник, который выбирает как будет лучше провести группу туристов через лес или какое-то иное место. И так, решено, создадим своего проводника, музыкой и печеньками.

Ближе к делу

И так, первым делом сформируем фильтр для контроллера, опишем входные данные, чтобы можно было начать писать тесты

before_action :inspect_route def inspect_route response = Services::App::Conductor.call( request: request, user: current_user, view_context: view_context ) return if response.blank? respond_to do |format| format.html { redirect_to response.url } format.json do render json: (response.notice.present? ? { message: response.notice } : {}) end end end
И так, разберем входные данные, очевидно, что нам нужен пользователь т.к. все проверки по большей части связаны именно с ним. Так же нам нужен текущий request по нему мы будем определять, нужно ли нам выполнять данную проверку, к примеру, исключать AJAX запросы,  view_context нужен нам для работы с методами контроллера.

Теперь определим структуру нашего сервисного класса, она будет выглядять примерно так

module Services module App class Conductor attr_reader :user, :request, :view_context def initialize(user:, request: nil, view_context: nil) @user = user @request = request @view_context = view_context end def self.call(user:, request: nil, view_context: nil) new( user: user, request: request, view_context: view_context ).call end def call; end end end end

Следующий шаг, мы должны определиться как будем проверять необхомость в редиректе, логично, что данный класс не должен знать правил перехода, а лишь перебирать варинты и возвращать результат. Для этого определим константу ROUTES, в ней мы определим список классов, отвечающие за отдельный вид редиректа, так же нам нужно реализовать сам метод, работающий с этим списком, наша цель, найти первый положительный результат.

ROUTES = [ Services::App::Routes::Agreement, Services::App::Routes::AccountVerification ].freeze def call return if destination.blank? destination.new(user, view_context).info end private def destination @_destination ||= ROUTES.find do |route| route.available?( user: user, request: request, view_context: view_context ) end end

Из этого кода видно, что класс для проверки роута должен реализовывать 2 метода, available? и info, первый описывает все необходимые проверки, а второй возвращаемые результаты - ссылку для перехода и сообщение.

module Services module App class Conductor attr_reader :user, :view_context def initialize(user, view_context = nil) @user = user @view_context = view_context end def self.available?(options) !options[:user].agreed? end def info OpenStruct.new( url: view_context.app_terms_path, notice: I18n.t('notifications.agreement_required') ) end end end end

Окей, базовая логика написана, что теперь? Теперь нам нужно корректировать работу этого класса, просто так мы не можем отключить before_action :inspect_route т.к. он должн работать для каждого контроллера и отключаться только если доходит до шага, который выполняет редирект на самого себя. В текущей реализации у нас произойдет зацикливание, чтобы исправить это добавим метод в контроллере который указывает на какой проверке мы должны остановится в нашем классе проводнике.

В контроллере добавляем private метод

def current_route Services::App::Routes::Agreement end

А  в нашем классе модифицируем метод destination следующим образом

def destination @_destination ||= ROUTES.find do |route| return if already_selected?(route) route.available?( user: user, request: request, view_context: view_context ) end end def already_selected?(route) current_route == route end def current_route return unless view_context&.controller.respond_to?(:current_route, true) view_context&.controller&.send(:current_route) end

И так, мы получили полностью работающий класс, способный управлять любым количеством редиректов в нашем приложении, скрыв всю логику в одном классе. Теперь можно соединить все описанные методы в итоговый класс, он будет выглядеть таким образом:

module Services module App class Conductor attr_reader :user, :request, :view_context ROUTES = [ Services::App::Routes::Agreement, Services::App::Routes::AccountVerification ].freeze def initialize(user:, request: nil, view_context: nil) @user = user @request = request @view_context = view_context end def self.call(user:, request: nil, view_context: nil) new( user: user, request: request, view_context: view_context ).call end def call return if invalid_client? || destination.blank? destination.new(user, view_context).info end private def invalid_client? request.xhr? end def destination @_destination ||= ROUTES.find do |route| return if already_selected?(route) route.available?( user: user, request: request, view_context: view_context ) end end def already_selected?(route) current_route == route end def current_route return unless view_context&.controller.respond_to?(:current_route, true) view_context&.controller&.send(:current_route) end end end end

При необходимости его можно модифицировать под дополнительные условия.

На этом все.