Существуют библиотеки на различных языках, имеющие общие черты. Это compojure, sinatra, grape, express, koa и подобные.


У них схожий подход к роутингу. Они не накладывают никаких ограничений и не предлагают структуру для организации url. Разработчики в таких условиях склонны не заботиться о структуре и впоследствии получают плохо поддерживаемый код.


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


Я расскажу, как улучшить поддерживаемость кода в экосистеме Clojure, и покажу, как:


  1. организовать url'ы
  2. структурировать код обработчиков
  3. использовать языковые конструкции для генерации url

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


Я — ruby разработчик, и в других экосистемах (clojure, js, erlang, go) мне не хватает организации роутинга, подобного rails. Мне не хватает REST и понятия "ресурс". Мне не хватает контроллеров. Мне не хватает хелперов для генерации url, вроде admin_page_path(@page).


Если вы не знакомы с rails, то вот ссылка на описание роутинга "Rails Routing from the Outside In". Если вы подзабыли, что такое HTTP или REST, то я советую прочитать короткую и шутливую статью "15 тривиальных фактов о правильной работе с протоколом HTTP" или более обстоятельную "Зачем нужен этот ваш REST, а также о некоторых тонкостях реализации RESTful приложений".


Итак, используйте REST для организации url.


Для того чтобы разобраться с оставшимися двумя пунктами, я покажу примеры использования своей библиотеки darkleaf/router. Т.к. это экосистема clojure, то, разумеется, это ring-совместимый роутинг.


(ns hello-world.core
  (:require [darkleaf.router :refer :all]))

(def pages-controller
  {:middleware (fn [handler] (fn [req] req))
   :member-middleware some-other-middleware
   :index (fn [req] some-ring-response)
   :show (fn [req] some-ring-response)})

(def routes
  (build-routes
   (resources :pages 'page-id pages-controller)))

(def handler (build-handler routes))
(def request-for (build-request-for routes))

(handler {:uri "/pages", :request-method :get}) ;; call index action from pages-controller
(request-for :index [:pages]) ;; returns {:uri "/pages", :request-method :get}

(handler {:uri "/pages/1", :request-method :get}) ;; call show action from pages-controller
(request-for :show [:pages] {:page-id "1"}) ;; returns {:uri "/pages/1", :request-method :get}

Здесь, подобно rails, объявляется контроллер. В данном случае с двумя экшенами: index и show.
Контроллер — это всего лишь map, и вы можете, к примеру, создавать незначительно отличающиеся контроллеры с помощью своей функции.
Routes — это плоский вектор роутов, сгенерированных функциями наподобие resources.
Handler — это ring-совместимый обработчик запросов, а request-for служит для получения запроса, по имени роута и его области. Они генерируются с помощью макросов, т.к. используют внутри core.match.


Контроллер может содержать только следующие ключи:


  • :middleware — оборачивает все экшены контроллера, включая обработчики вложенных роутов
  • :member-middleware — оборачивает только member actions и обработчики вложенных роутов, помеченные как member
  • collection actions: :index, :new, :create
  • member actions: :show, :edit, :update, :destroy

Вот полный пример для ресурса:


(resources :pages 'page-id {:index identity
                            :new identity
                            :create identity
                            :show identity
                            :edit identity
                            :update identity
                            :destroy identity}
           :collection
           [(action :archived identity)]
           :member
           [(resources :comments 'comment-id {:index identity})])

Первым аргументом указывается название ресурсов, им же задается сегмент в url. Вторым параметром задается название идентификатора ресурса.


Как я упоминал выше, ресурсы могут включать в себя вложенные роуты двух типов: collection и member.


;; pages collection routes
(request-for :archived [:pages] {}) ;; #=> {:uri "/pages/archived", :request-method :get}

;; pages member routes
(request-for :index [:pages :comments] {:page-id "some-id"}) ;; #=> {:uri "/pages/some-id/comments", :request-method :get}

Кроме функции-генератора роутов resources также есть: root, action, wildcard, not-found, scope, guard, resource. Я не буду на них останавливаться, подробные примеры их использования вы найдете в тестах.


Нет способа добавить в контроллер новые экшены. Это осознанное ограничение, подталкивающее к REST и вложенным ресурсам. Если вам все-таки нужны дополнительные экшены, и вы не хотите использовать вложенные ресурсы, то можно использовать вложенный роут, как это показано выше для роута :archived. Но это будет отдельная функция вне контроллера.


Как вы уже заметили, request-for возвращает структуру запроса целиком, в отличие от рельсовых хэлперов, возвращающих только url. Это полезно, когда для определения обработчика используются заголовки или другие параметры запроса, например, host. В следующих релизах планируется поддержка clojurescript, и вы сможете использовать тот же request-for для построения запросов к бэкенду.


Т.к. request-for возвращает запрос целиком, то в нем существует проверка, что полученный запрос попадет в нужный обработчик. Это полезно, когда 2 похожих url обрабатываются различным образом или присутствует ограничение


(guard :locale #{"ru" "en"}
       (action :localized-page identity))
(not-found itentity)       

(request-for :localized-page [:locale] {:locale "it"}) выдаст ошибку, т.к. обработчиком /it/localized-page будет роут not-found, а не :localized-page [:locale].


Библиотека поделена на 2 неймспейса:


darkleaf.router и darkleaf.router.low-level. Если у вас какие-то специфические требования к роутингу или необходимо поддерживать старую схему url, то можно написать свои функции поверх darkleaf.router.low-level, точно так же, как это сделано в darkleaf.router.


Полные примеры использования вы найдете в тестах darkleaf.router-test и darkleaf.router.low-level-test.


Библиотека использует внутри core.match и довольно занятные макросы. Но это уже тема отдельной статьи. Пишите в комментариях, если вам интересно узнать, как это работает внутри.

Поделиться с друзьями
-->

Комментарии (17)


  1. mkuzmin
    03.10.2016 12:15

    Рельсы используют «easy» подход, а clojure — «simple». Можно за это ругать рельсы, и многие упреки будут объективными, но можно взять из рельс лучшее, в частности роутинг. Если вы не сталкивались с rails, или есть какие-то предубеждения, то можно просто ознакомиться, как сделан роутинг у них — http://guides.rubyonrails.org/routing.html


    1. j_wayne
      04.10.2016 00:33

      В Rails инфраструктурные вещи вообще очень удобно сделаны. Кроме роутинга, вне Rails, мне не хватает миграций бд (flyway — жалкое подобие) и лейаутов. Выбирал из java/groovy/scala веб-фреймворков. Подобный маппинг имеет из коробки лишь Grails. Еще asset pipeline в rails удобен, но для частого сейчас SPA он как правило, не нужен.
      Еще рельсы очень быстро запускаются. Что хорошо для TDD, индивидуальный тест быстро стартует, даже если прогружает почти все рельсы (не integrational тест, что то из model/controller/view).


      1. Source
        04.10.2016 01:53

        Еще рельсы очень быстро запускаются.

        Шутите? 15-20 секунд — это очень быстро?
        Чтобы хоть как-то снизить время ожидания, придумана куча воркэраундов типа Zeus, Spork, Spring.


        1. j_wayne
          04.10.2016 09:02

          Нет, не шучу, только что проверил — полный прогон одного example rspec (указывается номер строки в коде) — 3-4 секунды.
          Пожилое rails 4 приложение на ruby 2.2.5 (71 гем в Gemfile).
          Для сравнения, play framework прогоняет пустой тест в голом (!) аппе за 10 секунд.

          Причиной такой долгой загрузки рельсов (15-20 сек) может быть большое кол-во мелких гемов.
          Прелоадеры терпеть не могу из-за глюков.


          1. Source
            04.10.2016 11:51

            Причиной такой долгой загрузки рельсов (15-20 сек) может быть большое кол-во мелких гемов.

            Или просто размер приложения :-)
            Впрочем, на мой взгляд, даже 3-4 секунды — это не очень быстро, хотя это уже приемлемо.
            А "очень быстро" — это про миллисекунды.


            1. j_wayne
              04.10.2016 12:01

              Хорошо, переформулирую, как — «сравнительно быстро»)
              Просто я сравнивал реальное приложение на rails и пустое на фреймворк N.


  1. Source
    04.10.2016 01:58

    (resources :pages 'page-id pages-controller)

    А почему не сделать resources макросом и до конца уж эмулировать роутинг в стиле Rails? Это ж Lisp, тут милое дело DSL создавать.


    (def routes
      (build-routes
        (resources :pages)))

    Кстати, есть поддержка неймспейсов и вложенных ресурсов? Что-то типа:


    (def routes
      (build-routes
        (namespace :admin
          (resources :pages
            (resources :comments)))))


    1. mkuzmin
      04.10.2016 09:48

      А почему не сделать resources макросом

      Макросы — это "темная магия" и без надобности ее применять не стоит. В данном случае все можно сделать через функции.


      Кстати, есть поддержка неймспейсов и вложенных ресурсов? Что-то типа:

      Конечно, в статье это упоминается, вот пример:


      (resources :pages 'page-id {:index identity
                                  :new identity
                                  :create identity
                                  :show identity
                                  :edit identity
                                  :update identity
                                  :destroy identity}
                 :collection
                 [(action :archived identity)]
                 :member
                 [(resources :comments 'comment-id {:index identity})])


      1. Source
        04.10.2016 11:57

        По-моему тут как раз надобность есть, я вон даже не заметил вложенный ресурс в коде, потому что он визуально перегружен. Например, какой смысл identity 9 раз писать?


        1. mkuzmin
          04.10.2016 12:00

          Действительно, зачем тут identity 9 раз написано?


          {:index identity
                                      :new identity
                                      :create identity
                                      :show identity
                                      :edit identity
                                      :update identity
                                      :destroy identity}

          это inline контроллер, а identity — обработчик-заглушка.


          Это второй пример использования и я показываю другой вариант, в первом примере используется отдельный контроллер.


          1. Source
            04.10.2016 12:27

            Ладно, я Вас понял. Вы считаете, что предлагаемый Вами синтаксис роутов удобно читать… Непонятно только зачем было сравнивать его с Rails, ведь сравнение не в вашу пользу :-)


            Ну, обработчик-загрушка и что? Что мешает написать примерно так:


            (resources :pages 'page-id identity :only '(:index, :new, :create, :show, :edit, :update, :destroy))

            Впрочем ладно, что-то я разошёлся… Через годик освоитесь с макросами, перестанете считать их "тёмной магией" и всё будет чудесно.


            1. mkuzmin
              04.10.2016 12:30

              Конечно, спасибо за комментарии.


  1. burfee
    05.10.2016 15:34

    Спасибо за статью.
    Насколько я понял все задачи, кроме генерации url неплохо решаются существующими библиотеками.
    Также есть библиотека bidi, которая и эту задачу решает. Кстати у них на странице есть сравнение с аналогами.
    Что касается стиля в виде ресурсов, есть liberator, в котором много чего наворочено.
    Я пробовал использовать и то, и другое. Чего не хватало — да, мало url, нужны для них имена, которые вместе с шаблоном url дают красивый способ конструирования параметризованного url. При всем при этом потребности в концепции «ресурсов» как то не ощущается, только лишнее усложнение. Короче если в compojure/secretary добавить имена — лучший вариант. ИМХО конечно же.
    Для себя решил вопрос, учитывая существующие ограничения, используя мультиметоды.
    использую обычный роутинг compojure/secretary, а внутри

    ;; page, build-url - мультиметоды
    ;; возвращаю страницу:
    (page <тип> <параметры>) ;; (page :index)
    ;; а для получения урл:
    (build-url <тип> <параметры>)
    
    

    Да, как бы оно не в одном месте, а размазано, но ИМХО это проще, на :default можно предупреждения прикрутить.
    Если интересно, могу написать, как сделал у себя SPA на react (rum) с серверным рендером и кросскомпиляцией.


    1. mkuzmin
      05.10.2016 15:35

      Я делаю проект-пример по работе с этой библиотекой, как раз покажу как с ресурсами работать.
      Да, я видел эти библиотеки, спасибо.
      Рендеринг через nashorn?


      1. burfee
        05.10.2016 15:44

        нет, вроде свой, попроще


        1. mkuzmin
          05.10.2016 15:47

          Это работает только если вы все компоненты на rum делаете, а если нужно какой-то внешний реакт-компонент, то это не сработает. Нужен какой-то js движок: nodejs или nashorn


          1. burfee
            05.10.2016 16:03

            Да, пока хватает. В любом случае интересно почитать о вашем решении, если напишете потом.