Радостное знакомство с Clojure: Часть III

"

Когда вы изучаете новый язык программирования, сам язык - это только половина дела.

В большинстве языковых руководств основное внимание уделяется функциям самого языка , игнорируя вторую половину истории: чтобы выполнить НАСТОЯЩУЮ работу, вам также необходимо знать экосистема инструментов, окружающих язык.

Теперь, когда вы изучили некоторые основы Clojure в Части I и Части II, пора заняться разработкой приложений.

Мы собираемся создать революционно новый вид приложения ...

… Служба сокращения URL-адресов.

Эй, не смотри на меня так, это хорошее обучающее упражнение.

В этой статье мы начнем с достаточно простой версии, а позже сделаем ее более сложной.

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

Итак, что мы снова делаем?

Конечная цель - познакомить вас с разработкой приложений на Clojure настолько, чтобы вы чувствовали себя комфортно, выбирая Clojure для своего следующего программного проекта, будь то побочный проект для развлечения или профессиональное бизнес-приложение.

Мы собираемся создать службу сокращения URL под названием Shortify. В этой статье мы начнем с простой версии, в которой в основном используется уже знакомый вам Clojure. Затем, в следующих нескольких статьях, мы узнаем о некоторых более продвинутых функциях языка Clojure, воспользуемся этими функциями для создания более надежной версии приложения Shortify и создадим веб-интерфейс для приложения с использованием ClojureScript, Clojure- компилятор to-Javascript.

Пристегнитесь, давайте нырнем (я могу смешивать здесь метафоры ...)

Обзор

На высоком уровне базовая служба сокращения URL-адресов должна уметь делать две вещи:

  1. Пользователь может отправить URL-адрес для сокращения, а служба сохраняет URL-адрес и возвращает идентификатор пользователю.
  2. Пользователь может отправить идентификатор, соответствующий сохраненному URL-адресу, и служба вернет URL-адрес.

Мы реализуем это поведение с помощью HTTP-сервера и базы данных PostgreSQL:

Давайте рассмотрим это по частям. Если вы еще этого не сделали, откройте образец приложения в Atom:

cd /path/to/joyful_clojure/03_clojure_application_development
atom .

База данных

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

При желании вы можете пропустить Docker и запустить PostgreSQL прямо на локальном компьютере. В этом случае вам нужно будет соответствующим образом настроить параметры базы данных проекта.

Если вы планируете использовать Docker, установите Docker Community Edition, если у вас его еще нет. После того, как вы установили Docker, вы можете протестировать свою установку, запустив контейнер hello-world:

docker run hello-world

После того, как вы настроили и запустили Docker, вы можете запустить движок PostgreSQL в контейнере Docker, используя scripts/dev_database.sh скрипт в репозитории примеров.

Прежде чем запустить скрипт, давайте посмотрим, что он делает:

#! /bin/bash
docker run \
  --name=local-postgres \
  --rm \
  -e POSTGRES_USER=postgres \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -d \
  -p 8082:5432 \
  -v $HOME/docker/volumes/postgres:/var/lib/postgresql/data \
  postgres:alpine

Мы собираемся запустить postgres:alpine образ контейнера и присваиваем имя работающему контейнеру local-postgres.

Параметр --rm указывает, что контейнер будет удален после остановки. -d указывает, что контейнер будет в «отключенном режиме», что означает, что он будет работать в фоновом режиме, а не на переднем плане текущего терминала.

Переменные среды, заданные параметрами -e, указывают учетные данные для входа, которые следует использовать для подключения к базе данных после ее запуска. Поскольку эта база данных предназначена только для локальной разработки, мы можем использовать пользователя postgres по умолчанию и жестко заданную строку в качестве пароля.

Параметр -p связывает порт 5432 внутри контейнера с портом 8082 вне контейнера. Сервер PostgreSQL внутри контейнера прослушивает соединения на порту 5432, поэтому мы сможем подключиться к серверу извне контейнера, отправив запросы на порт 8082.

Наконец, опция -v связывает каталог в вашей файловой системе с каталогом в файловой системе контейнера. Сервер PostgreSQL внутри контейнера сохраняет свои данные в /var/lib/postgresql/data, и, связав эти два каталога, данные будут записаны в файловую систему вне контейнера в $HOME/docker/volumes/postgres. Это позволяет данным в вашей базе данных разработки сохраняться на вашем жестком диске, даже когда контейнер Docker остановлен и удален.

Получил все это? Запустим контейнер базы данных:

scripts/dev_database.sh

Если это сработает, вы должны увидеть распечатанный хэш-идентификатор в вашем терминале, но больше ничего не должно произойти. Вы можете проверить, запущен ли контейнер, запустив docker container ls, который должен показать контейнер local-postgres.

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

Затем давайте подключимся к работающему серверу PostgreSQL с помощью psql клиента командной строки. Я предоставил еще один сценарий оболочки для запуска необходимой команды Docker:

scripts/dev_database_connection.sh

После запуска вы должны увидеть что-то вроде этого:

psql (11.1)
Type "help" for help.
postgres=#

Давайте создадим базу данных для разработки с помощью команды CREATE DATABASE:

postgres=# CREATE DATABASE url_shortening_db_dev;

Затем создайте еще одну базу данных для тестирования:

postgres=# CREATE DATABASE url_shortening_db_test;

Вы можете подключиться к базе данных разработки с помощью команды \c:

postgres=# \c url_shortening_db_dev
You are now connected to database "url_shortening_db_dev" as user "postgres".
url_shortening_db_dev=#

Вы можете просмотреть доступные таблицы данных с помощью команды \d:

url_shortening_db_dev=# \d
Did not find any relations.
url_shortening_db_dev=#

У нас пока нет таблиц, поэтому это сообщение неудивительно.

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

Конфигурация приложения Clojure

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

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

Мы собираемся добиться этого с помощью библиотеки Environment для Clojure.

Библиотека среды объединяет все параметры конфигурации, переданные приложению, в единую карту с именем env в пространстве имен environ.core. Он получает эти параметры конфигурации из двух мест: переменных среды и файла .lein-env, если он существует.

Файл .lein-env не создается вручную - вместо этого плагин lein-environ Leiningen автоматически генерирует .lein-env, просматривая ключ :env на карте конфигурации project.clj.

Откройте project.clj и посмотрите разделы :dependencies и :plugins:

:dependencies [[org.clojure/clojure "1.9.0"]
               [org.clojure/data.json "0.9.0"]
               [ring "1.7.1"]
               [ring/ring-json "0.4.0"]
               [ring-logger "1.0.1"]
               [compojure "1.6.1"]
               [org.postgresql/postgresql "42.2.5"]
               [ragtime "0.7.2"]
               [environ "1.1.0"]
               [clj-time "0.15.0"]
               [com.taoensso/timbre "4.10.0"]]
:plugins [[lein-ring "0.12.5"]
          [lein-environ "1.1.0"]]

Как и раньше, раздел :dependencies включает зависимости кода нашего приложения. Пока не беспокойтесь обо всем этом - пока обратите внимание, что библиотека environ была включена.

В разделе :plugins объявляются плагины Leiningen, которые следует использовать в этом проекте. Плагины Leiningen обычно добавляют к Leiningen дополнительные функции, которые можно использовать в процессе сборки. В этом случае мы включили плагин lein-environ, который автоматически генерирует .lein-env файл конфигурации.

Затем взгляните на раздел :profiles:

:profiles
{:dev {:env {:environment "development"}}
 :test {:env {:environment "test"}
        :dependencies [[pjstadig/humane-test-output "0.9.0"]]
        :injections [(require 'pjstadig.humane-test-output)
                     (pjstadig.humane-test-output/activate!)]}
 :prod {:env {:environment "production"}
        :uberjar-name "app-standalone.jar"
        :main main
        :aot :all}})

Leiningen позволяет вам определять разные «профили», которые определяют параметры конфигурации для разных сред (помните файл ~/.lein/profiles.clj, в котором мы объявили зависимость от Proto-Repl?).

Давайте запустим REPL с активным профилем dev:

lein with-profile +dev repl

После запуска REPL вы должны увидеть, что файл .lein-env был сгенерирован в каталоге проекта. Git должен игнорировать этот файл. Файл .gitignore в репозитории примеров уже настроен на игнорирование этого файла.

Если вы откроете .lein-env, вы должны увидеть что-то вроде этого:

{:environment "development"}

Обратите внимание, что .lein-env заполняется содержимым ключа :env в профиле dev.

Остановите свой REPL и взгляните на пространство имен db.core внутри папки src проекта:

(ns db.core
  (:require [environ.core :refer [env]]))
(def connection
  "Map representing the database connection."
  {:dbtype (:database-type env)
   :dbname (:database-name env)
   :user (:database-username env)
   :password (:database-password env)
   :host (:database-host env)
   :port (:database-port env)})

Библиотека environ предоставляет пространство имен environ.core, из которого мы импортируем карту env. Эта карта объединяет параметры конфигурации из .lein-env с любыми переменными среды, переданными в приложение.

Также обратите внимание, что в этом выражении я использую ключевые слова как функции. Ключевое слово может использоваться как функция для поиска соответствующего значения на карте. Другими словами, (:database-type env) эквивалентно (get env :database-type)

Здесь есть проблема: мы заполнили .lein-env файл только одним параметром конфигурации, {:environment "development"}. Однако это пространство имен ожидает несколько других ключей, которые настраивают соединение с базой данных: :database-type, :database-name и так далее.

Наивно, мы могли бы попробовать включить эти параметры конфигурации в профиль dev, определенный в project.clj. Однако обычно плохая идея проверять конфигурацию локальной базы данных в репозитории Git.

Вот уловка: вспомните, что Leiningen может также извлекать параметры конфигурации из файлов с именем profiles.clj - это то, что мы использовали для объявления зависимости от Proto Repl. Итак, чтобы настроить подключение к локальной базе данных, мы можем создать profiles.clj файл в каталоге проекта и указать Git игнорировать его.

В репозитории примера файл .gitignore уже настроен на игнорирование profiles.clj. Создайте файл profiles.clj в папке проекта 03_clojure_application_development и добавьте следующее содержимое:

{:dev-local {:env {:port "8080"
                   :database-type "postgresql"
                   :database-name "url_shortening_db_dev"
                   :database-username "postgres"
                   :database-password "mysecretpassword"
                   :database-host "localhost"
                   :database-port "8082"}}
 :test-local {:env {:port "9090"
                    :database-type "postgresql"
                    :database-name "url_shortening_db_test"
                    :database-username "postgres"
                    :database-password "mysecretpassword"
                    :database-host "localhost"
                    :database-port "8082"}}}

Обратите внимание, что здесь мы определяем два новых профиля: профиль dev-local, который настраивает подключение к базе данных разработки, которую мы создали внутри контейнера Docker, и профиль test-local, который настраивает подключение к тестовой базе данных. Если вы решили не использовать контейнер Docker для запуска PostgreSQL, вам нужно будет соответствующим образом изменить эти параметры конфигурации.

Давайте снова запустим REPL с новым профилем dev-local:

lein with-profile +dev,+dev-local repl

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

Подключите ваш редактор к REPL и перейдите в пространство имен db.core. Если все работает правильно, карта env должна содержать параметры конфигурации из профиля dev-local:

Добавление таблиц в базу данных

Теперь давайте добавим в нашу базу данных таблицу URL-адресов для хранения сокращенных URL-адресов. Мы могли сделать это с помощью оператора CREATE TABLE в клиенте командной строки psql, но этот подход не очень масштабируем.

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

Мы будем хранить файлы миграции в каталоге resources/migrations и использовать библиотеку ragtime для управления этими миграциями.

Для каждой миграции базы данных нам нужно указать, как добавлять новые таблицы в базу данных, а также как их удалить, если нам нужно вернуться к более ранней версия базы данных. Рэгтайм ожидает, что мы сохраним эти два компонента миграции как два разных файла: один с расширением up.sql, а другой с down.sql.

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

Взгляните на миграцию xxxx-create-urls-table.up.sql:

CREATE TABLE urls (
  id VARCHAR(255) NOT NULL UNIQUE PRIMARY KEY,
  url VARCHAR(255) NOT NULL
);

В нашей таблице urls 2 столбца: id и url.

Давайте запустим эту миграцию, чтобы создать urls таблицу.

Откройте пространство имен db.migration в своем редакторе и также перейдите в это пространство имен в своем REPL.

Обратите внимание на пару вещей:

  1. Некоторые функции в этом пространстве имен определены с defn- вместо defn. Это указывает на то, что эти функции являются частными и не могут быть импортированы в другие пространства имен.
  2. Вверху файла мы вводим функции из нескольких других пространств имен, используя форму :require. Кроме того, мы напрямую импортируем org.postgresql.util.PSQLException класс Java, используя форму :import.
  3. Это пространство имен предоставляет 4 общедоступные функции: create-migration!, migrate-up!, rollback! и migrate-down!.

Запустите функцию migrate-up!, чтобы создать новую таблицу в базе данных:

Ragtime отслеживает, какие миграции уже были выполнены. Если вы снова запустите migrate-up!, Ragtime знает, что миграция create-urls-table уже произошла, поэтому он не будет пытаться запустить его снова:

Обратите внимание, что сообщение «apply xxxx-create-urls-table» не было напечатано во второй раз, что указывает на то, что ragtime не вносил никаких изменений в базу данных.

Снова подключитесь к базе данных с помощью scripts/dev_database_connection.sh, переключитесь на базу данных разработки и составьте список таблиц:

postgres=# \c url_shortening_db_dev
You are now connected to database "url_shortening_db_dev" as user "postgres".
url_shortening_db_dev=# \d
               List of relations
 Schema |        Name        | Type  |  Owner   
--------+--------------------+-------+----------
 public | ragtime_migrations | table | postgres
 public | urls               | table | postgres
(2 rows)

Как и ожидалось, таблица urls была создана. Рэгтайм также создал ragtime_migrations таблицу, чтобы отслеживать, какие миграции уже произошли.

Вы можете получить более подробную информацию об отдельной таблице, передав ее в качестве аргумента команде \d:

url_shortening_db_dev=# \d urls
                       Table "public.urls"
 Column |          Type          | Collation | Nullable | Default 
--------+------------------------+-----------+----------+---------
 id     | character varying(255) |           | not null | 
 url    | character varying(255) |           | not null | 
Indexes:
    "urls_pkey" PRIMARY KEY, btree (id)

Наша urls таблица создана, но в настоящее время в ней нет строк:

url_shortening_db_dev=# SELECT * FROM urls;
 id | url 
----+-----
(0 rows)

Давайте добавим фальшивые данные в базу данных, чтобы нам было на что посмотреть при запуске сервера.

Заполнение базы данных

Вставка исходных данных в новую базу данных называется заполнением базы данных. По этой причине мы будем хранить фальшивые файлы данных в каталоге resources/seeds.

Откройте файл urls.edn в каталоге resources/seeds:

[{:table :urls
  :data [{:id "12345"
          :url "https://someawesomewebsite.com"}
         {:id "67890"
          :url "https://anotherwebsite.com?some=param"}
         {:id "23456"
          :url "https://myspace.com"}]}]

Обратите внимание, что мы определяем 3 записи для таблицы urls в этом файле.

Расширение файла .edn означает «расширяемая нотация данных» и примерно эквивалентно файлу JSON, за исключением того, что для представления данных в нем используется синтаксис Clojure вместо синтаксиса Javascript. Clojurists отметили бы, что EDN предлагает некоторые убедительные преимущества по сравнению с JSON, но это уже история для другого раза.

На высоком уровне, чтобы загрузить данные в базу данных, нам необходимо сделать следующее:

  1. Получить список всех файлов в каталоге resources/seeds
  2. Прочитать текст каждого файла в память
  3. Разберите текст каждого файла на правильные структуры данных
  4. Вставьте данные из каждого файла в нашу базу данных SQL в виде строк таблицы

Эти задачи выполняются в пространстве имен db.seed. Откройте его в редакторе, и давайте разберемся с новыми концепциями.

Я несколько раз упоминал, что Clojure работает на виртуальной машине Java (JVM). Одна из явных целей дизайна Clojure - охватить возможности хост-платформы. В результате многие общие задачи ввода-вывода в Clojure выполняются с использованием тонких оболочек над проверенными библиотеками Java.

В пространстве имен db.seed мы используем две такие библиотеки: clojure.java.io используется для чтения и записи в файловую систему и представляет собой тонкую оболочку для пакета java.io. Между тем, clojure.java.jdbc - это стандартный способ чтения и записи в базы данных SQL и тонкая оболочка библиотеки JDBC.

Давайте сначала посмотрим на функцию insert-seed!.

(defn- insert-seed!
  "Inserts the contents of a single seed file into the database."
  [seed]
  (doseq [{:keys [table data]} seed]
    (jdbc/insert-multi! connection table data)))

Ожидается, что параметр seed будет содержимым исходного файла, который представляет собой вектор карт, каждая из которых содержит ключи :table и :data, определяющие список строк, которые нужно вставить в одну таблицу. Для каждой карты мы вызываем функцию jdbc/insert-multi!, которая принимает следующие параметры:

  • connection, карта, представляющая расположение базы данных и учетные данные
  • table, ключевое слово, представляющее имя таблицы
  • data, последовательность карт, каждая из которых представляет структуру строки в таблице

Далее функция get-all-seed-files:

(defn- get-all-seed-files
  "Gets a sequence of File objects representing all the files
   in the seeds directory."
  []
  (.listFiles (io/file (io/resource "seeds"))))

Здесь происходит 3 шага: во-первых, функция io/resource создает абсолютный URL-адрес чего-либо в каталоге resources. Затем функция io/file создает объект java.io.File, представляющий цель этого URL-адреса, которым в данном случае является каталог seeds. Наконец, мы вызываем метод .listFiles, чтобы получить последовательность объектов java.io.File, представляющих все файлы в этом каталоге.

Откуда взялась эта .listFiles функция? Поскольку Clojure охватывает базовую платформу JVM, вы можете напрямую вызывать методы для объектов Java из Clojure:

// Java syntax
file.listFiles()
;; Clojure syntax
(.listFiles file)

Если имя функции начинается с ., компилятор Clojure будет искать метод с таким именем в переданном объекте вместо поиска функции с таким именем в текущем пространстве имен.

Для завершения процесса заполнения нам понадобятся две другие функции: функция slurp считывает все содержимое файла в память в виде строки, а функция edn/read-string анализирует строку данных .edn в структуру данных Clojure, аналогично тому, как функция JSON.parse работает в Javascript.

Весь процесс вставки исходных данных в базу данных реализуется функцией insert-all-seeds!:

(defn insert-all-seeds!
  "Reads all files in the seeds directory and inserts their contents into
   the database."
  []
  (->> (get-all-seed-files)
       (map slurp)
       (map edn/read-string)
       (map insert-seed!)
       doall))

Здесь мы используем макрос последнего потока ->>, который мы ввели в прошлый раз.

Здесь есть еще одна хитрость: вспомните, что map возвращает отложенную последовательность, что означает, что значения в последовательности фактически не вычисляются, пока мы не попытаемся прочитать значения из последовательности. В этом случае мы хотим, чтобы все вычисления выполнялись немедленно, чтобы начальные числа фактически были вставлены в базу данных. Функция doall заставляет немедленно оценивать все элементы ленивой последовательности.

Получил все это? Давайте запустим функцию insert-all-seeds!, чтобы заполнить вашу базу данных данными. Убедитесь, что вы сначала перешли в пространство имен db.seed в REPL:

Если все прошло хорошо, теперь у вас должно быть 3 строки в urls таблице в базе данных:

url_shortening_db_dev=# SELECT * FROM urls;
  id   |                  url                  
-------+---------------------------------------
 12345 | https://someawesomewebsite.com
 67890 | https://anotherwebsite.com?some=param
 23456 | https://myspace.com
(3 rows)

Боковое примечание: что произойдет, если мы снова запустим функцию семени? Мы устанавливаем ограничение UNIQUE на столбец id, поэтому процесс заполнения должен завершиться ошибкой. Действительно, именно это и происходит:

На этом наша база данных готова. Далее поговорим о самом сервере.

Сервер

Для создания нашего http-сервера мы собираемся использовать две основные библиотеки: ring для абстракции базового сервера и compojure для маршрутизации.

Звенеть

Как и HTTP-серверы, структура кольца удивительно проста: сервер представлен одной функцией обработчика, которая принимает карту, представляющую запрос, в качестве параметра и возвращает карту, представляющую ответ. Вот полный http-сервер с двумя маршрутами и ответом по умолчанию «не найден»:

(defn handler
  [req]
  (case (:uri req)
    "/" {:status 200
         :headers {"Content-type" "text/plain"}
         :body "Hello world!"}
    "/goodbye" {:status 200
                :headers {"Content-type" "text/plain"}
                :body "Goodbye world!"}
    {:status 404
     :headers {"Content-type" "text/plain"}
     :body "Not found"}))

Не нужно запоминать сложного API, сервер - это всего лишь простая функция, которая принимает данные и возвращает данные. Именно это мы имеем в виду, когда говорим, что Clojure поддерживает программирование, ориентированное на данные.

Как и во многих библиотеках HTTP-серверов, в кольце есть промежуточное ПО, отражающее идею выполнения дополнительной обработки каждого запроса и ответа, такой как протоколирование каждого запроса или автоматическая сериализация тела ответа в строку JSON.

В кольце промежуточное ПО реализовано как функция, которая принимает обработчик и возвращает измененный обработчик.

Например, вот промежуточное ПО, которое печатает каждый запрос:

(defn wrap-request-printing
  [handler]
  (fn [req]
    (println req)
    (handler req)))

Вот промежуточное ПО, которое использует json/write-string из data.json для сериализации тела ответа в строку JSON, предполагая, что ключ :body в ответе является структурой данных Clojure:

(defn wrap-json-response
  [handler]
  (fn [req]
    (let [res (handler req)]
      (-> res
          (update :body json/write-str)
          (assoc-in [:headers "Content-type"]
                    "application/json")))))

В кольцевом приложении обычно определяется app переменная, представляющая ваш обработчик, в оболочке с несколькими промежуточными программами:

(def app
  (-> handler
      wrap-request-printing
      wrap-json-response))

В примере приложения это делается в пространстве имен app:

(ns app
  (:require [ring.middleware.json :refer [wrap-json-body
                                          wrap-json-response]]
            [middleware.logging :refer [wrap-logging]]
            [middleware.error-handling :refer [wrap-error-handling]]
            [routes :refer [root-handler]]))
(def app
  "Main Ring handler for the application"
  (-> root-handler
      wrap-error-handling
      wrap-logging
      ;; Serialize the response body into JSON
      wrap-json-response
      ;; If the request body contains JSON, parse it into a Clojure
      ;; data structure, converting string keys to keywords
      (wrap-json-body {:keywords? true})))

Обычно вы можете найти большую часть необходимого промежуточного программного обеспечения в библиотеках, таких как промежуточное программное обеспечение wrap-json-body и wrap-json-response, которое я использую из библиотеки ring-json.

Однако полезно иметь возможность создавать собственное промежуточное программное обеспечение для обработки ошибок, что выполняется в пространстве имен middleware.error-handling:

(ns middleware.error-handling)
(defn get-response-from-error
  "Converts a thrown error into the response that should be
   sent back to the client."
  [error]
  (let [type (get (ex-data error) :type)]
    (case type
      :not-found {:status 404 :body "Not found"}
      {:status 500 :body "Internal server error"})))
(defn wrap-error-handling
  "Ring middleware that catches any thrown errors and sends an appropriate
   response back to the client."
  [handler]
  (fn [req]
    (try
      (handler req)
      (catch Exception e
        (get-response-from-error e)))))

Функции обработчика приложения определены в пространстве имен urls. Например, create-url-handler считывает тело запроса, создает URL-адрес в базе данных и возвращает ответ 201, если создание прошло успешно:

(defn create-url-handler
  [req]
  (let [url (get-in req [:body :url])
        row (create-url! url)]
    {:status 201 :body row}))

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

Compojure

Как и кольцо, compojure очень проста: он просто определяет набор макросов, которые позволяют нам объединить несколько функций-обработчиков в одну без ручного написания if операторов для проверки URL-адреса и метода запроса.

Обработчик единого кольца нашего приложения определен в пространстве имен routes:

(ns routes
  (:require [compojure.core :refer :all]
            [compojure.route :as route]
            [urls]))
(defroutes root-handler
  (GET "/urls/:id" [] urls/get-url-handler)
  (POST "/urls" [] urls/create-url-handler)
  (route/not-found "Not found"))

Обратите внимание, что мы используем :refer :all в операторе require для comopjure.core, что означает, что defroutes, GET, POST и все другие функции, определенные в compojure.core, можно использовать в этом пространстве имен.

В целом наш сервер в настоящее время реализует следующее поведение:

  • Есть 2 пути: один для создания новых коротких URL-адресов, а второй для получения существующих URL-адресов. Если запрос отправляется по любому другому пути, по умолчанию отправляется ответ «Не найдено».
  • Если тело запроса содержит JSON, оно автоматически анализируется в структуру данных Clojure промежуточным программным обеспечением wrap-json-body.
  • Если тело ответа содержит структуру данных Clojure, оно автоматически сериализуется в JSON с помощью промежуточного программного обеспечения wrap-json-response.
  • Если при обработке запроса возникает ошибка, она перехватывается wrap-error-handling промежуточным программным обеспечением, и клиенту отправляется соответствующий ответ.
  • Каждый запрос регистрируется промежуточным программным обеспечением wrap-logging.

Теперь, когда мы изучили компоненты кодовой базы, давайте фактически запустим сервер.

Запуск сервера разработки

При желании мы могли бы запустить сервер прямо из REPL, вызвав функцию main/-main. Однако есть серьезная проблема с этим подходом: когда мы обновляем REPL, работающий сервер не улавливает изменения. Нам нужно будет вручную останавливать и перезапускать сервер каждый раз, когда мы вносим изменения.

Чтобы обойти эту проблему и улучшить опыт разработки, мы собираемся использовать плагин lein-ring Leiningen.

Обратите внимание, что я включил lein-ring в ключ :plugins в project.clj. Я также включил некоторые параметры конфигурации плагина в ключ :ring:

:ring {:handler app/app
       :port 8080
       :nrepl {:start? true :port 8081}
       :auto-reload? true}

Как предполагают параметры конфигурации, плагин запустит http-сервер на порту 8080, используя app/app в качестве основного обработчика Ring. Сервер автоматически перезагрузится, когда мы внесем изменения в наши исходные файлы.

Плагин также запустит сервер nREPL на порту 8081, что позволит нам подключить наш редактор так же, как если бы мы запустили обычный lein repl.

Итак, как мы на самом деле запускаем сервер? Плагин фактически добавляет в Leiningen несколько новых команд интерфейса командной строки. Попробуйте запустить следующее:

lein with-profile +dev,+dev-local ring server-headless

Вы должны увидеть несколько сообщений, напечатанных в консоли, о том, что http-сервер и сервер nREPL теперь работают.

Попробуйте сделать запрос к серверу с другого терминала:

curl localhost:8080

Сервер должен ответить Not found. Это ожидаемо, потому что мы не определили "/" маршрут в нашем обработчике. Вы также должны увидеть некоторую информацию о запросе, зарегистрированном в консоли. За это отвечает промежуточное ПО wrap-logging.

Вы также должны иметь возможность подключить Atom к серверу nREPL, работающему на порту 8081 (попробуйте, чтобы убедиться).

Давайте (наконец!) Воспользуемся нашим API для создания сокращенного URL:

curl localhost:8080/urls \
  -X POST \
  -H "Content-type: application/json" \
  -d '{"url": "https://some-other-website.com"}'

Сервер должен ответить строкой json, содержащей поля "id" и "url". Кроме того, давайте проверим базу данных разработки, чтобы убедиться, что новая запись действительно добавлена ​​(замените значение id тем, которое было возвращено с сервера):

url_shortening_db_dev=# SELECT url
url_shortening_db_dev-# FROM urls
url_shortening_db_dev-# WHERE id = 'b371e020-63c8-47b0-aa7b-83226d183788';
              url               
--------------------------------
 https://some-other-website.com
(1 row)
url_shortening_db_dev=#

Все идет нормально. Наконец, давайте проверим, может ли сервер искать URL-адрес по идентификатору (опять же, замените идентификатор в URL-адресе запроса своим значением):

curl localhost:8080/urls/b371e020-63c8-47b0-aa7b-83226d183788

Сервер должен вернуть документ JSON, представляющий строку базы данных.

Отлично, наш сервер работает! Конечно, мы проверили только «счастливые пути». Чтобы убедиться, что наш сервер устойчив к сбоям, было бы неплохо провести на нем некоторое автоматическое тестирование.

Репозиторий примеров не имеет очень тщательного тестового покрытия (это будет ваша работа), но есть несколько модульных тестов и интеграционных тестов. Поговорим о каждом по очереди.

Модульное тестирование

В файле test/middleware/error_handling_test.clj, который определяет пространство имен middleware.error-handling-test, есть несколько модульных тестов для промежуточного программного обеспечения обработки ошибок.

Чрезвычайно важное примечание: обратите внимание на соответствие между пространствами имен и именами файлов. Файл, определяющий пространство имен, должен удовлетворять двум условиям:

  1. Файл должен находиться в папке верхнего уровня, объявленной в ключах :source-paths или :test-paths в project.clj. Для тех из вас, кто знаком с JVM, это технически означает, что ваш файл должен находиться «в пути к классам».
  2. Относительный путь к файлу из папки верхнего уровня должен точно соответствовать имени пространства имен, при этом все символы «-» заменены на «_».

Это требование, чтобы компилятор Clojure мог найти ваши файлы. Самая распространенная ошибка, которую я делаю, - это нарушить условие 2, называя файлы тире вместо подчеркивания. Итак, если я попытаюсь определить пространство имен middleware.error-handling-test в файле с именем middleware/error-handling-test.clj, я получу сообщение об ошибке, указывающее на мою ошибку:

Could not locate middleware/error_handling_test__init.class or middleware/error_handling_test.clj on classpath. Please check that namespaces with dashes use underscores in the Clojure file name.

Не будь таким, как я. Не забудьте называть файлы подчеркиванием, а не тире.

Хорошо, вернемся к модульному тестированию.

Напомним, что в Части I этого руководства я упоминал, что мы можем использовать REPL для запуска наших тестов. Давайте посмотрим на пространство имен middleware.error-handling-test:

(ns middleware.error-handling-test
  (:require [clojure.test :refer :all]
            [middleware.error-handling :refer :all]))
(deftest test-get-response-from-error
  (testing "should return a 404 response when the exception data
            includes :type :not-found"
    (let [e (ex-info "Boom" {:type :not-found})
          response (get-response-from-error e)]
      (is (= (:status response) 404))))
  (testing "should return a 500 response otherwise"
    (let [e (ex-info "Boom" {:type :wrong})
          response (get-response-from-error e)]
      (is (= (:status response) 500)))))

Тестовые примеры, определенные с помощью deftest, на самом деле являются просто функциями, и мы можем запустить тест, напрямую вызвав функцию в REPL:

Обратите внимание на то, что я только что сделал:

  1. Я перешел в пространство имен middleware.error-handling-test
  2. Я вызвал функцию test-get-response-from-error. Об ошибках не сообщалось, что означает, что тест пройден.
  3. Чтобы показать, что происходит, когда тест не проходит, я изменил условие в тесте так, чтобы он не прошел.
  4. Обновил REPL
  5. Я снова вызвал функцию test-get-response-from-error, и в REPL появилась ошибка.

Этот подход работает, но для повторного запуска тестов требуется много шагов. К счастью, Proto-Repl включает несколько удобных команд для автоматизации этого процесса:

Мне больше всего нравится «Запуск тестов в пространстве имен», но все три из них автоматически обновят ваш REPL и перейдут в текущее пространство имен, поэтому вам не придется делать это вручную:

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

Интеграционное тестирование с базой данных

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

Мы можем добиться этого, выполнив для каждого тестового примера следующее:

  1. Запустите migrate-down!, чтобы удалить все таблицы и данные из базы данных.
  2. Запустите migrate-up!, чтобы воссоздать все таблицы
  3. Запустите insert-all-seeds!, чтобы добавить известные данные в базу данных.
  4. Запустите фактический тестовый пример

Если один тестовый пример изменяет состояние базы данных, никакие другие тестовые примеры не будут затронуты.

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

Файл tests/routes_test.clj содержит несколько основных интеграционных тестов. Откройте его и обратите внимание на следующий вызов вверху файла:

(use-fixtures :each with-database-reset)

Здесь мы заявляем, что хотим запускать функцию with-database-reset перед каждым тестовым примером. Мы скоро рассмотрим определение функции.

Я упоминал ранее, что вы можете использовать REPL для запуска ваших тестов во время разработки, но когда дело доходит до интеграционных тестов, есть сложная деталь: мы хотим запускать наши интеграционные тесты с базой данных url_shortening_db_test, но REPL разработки выполняется с профилем :dev-local , что означает, что вместо этого мы подключены к базе данных url_shortening_db_dev.

Следовательно, если я запускаю свои интеграционные тесты из REPL, моя база данных development будет сброшена, а не выделенная база данных testing!

Это решаемая проблема, но она требует переосмысления архитектуры приложения. Мы сделаем это в более поздней статье этой серии, но пока мы просто запретим запускать интеграционные тесты из разрабатываемого REPL и вместо этого будем использовать средство запуска тестов Leiningen.

Откройте файл test_helpers.clj и посмотрите определение функции with-database-reset:

(defn with-database-reset
  [run-tests]
  ;; Implementation
  )

Обратите внимание, что эта функция фиксации принимает в качестве параметра функцию обратного вызова run-tests. Как следует из названия, этот обратный вызов выполнит фактический тестовый пример. Следовательно, мы можем поместить код настройки перед вызовом run-tests и код разрыва после.

Обратите внимание, что этот прибор выдает ошибку, если (:environment env) не "test". Это наш неуклюжий способ запретить запуск тестов в базе данных разработки.

Вместо этого мы можем запустить интеграционные тесты из командной строки с Leiningen:

lein with-profile +test,+test-local test :once

Обратите внимание, что мы используем профили test и test-local, которые будут использовать тестовую базу данных, которую мы настроили в profiles.clj.

Вы, вероятно, увидите в консоли много чего напечатанного (это промежуточное программное обеспечение для кольцевого ведения журнала), но в конце вы должны увидеть что-то вроде этого:

Ran 3 tests containing 8 assertions.
0 failures, 0 errors.

Заключение и упражнения

Ладно, это было много. Подведем итоги того, что мы сделали:

  • Настройте сервер PostgreSQL внутри контейнера Docker и создайте базы данных разработки и тестирования внутри контейнера.
  • Используется environ и profiles.clj для настройки соединения с базой данных для различных сред приложений.
  • Применены миграции для изменения структуры базы данных и начальные числа для заполнения базы данных исходными данными.
  • Узнал о создании серверов с помощью обработчиков и промежуточного программного обеспечения.
  • Узнал об использовании Compojure для создания нескольких обработчиков с логикой маршрутизации.
  • Использовал плагин lein-ring для запуска сервера разработки с автоматической перезагрузкой.
  • Запускайте модульные тесты из REPL разработки и интеграционные тесты из командной строки.

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

Упражнение 1

Прямо сейчас пользователь просто возвращает случайный UUID, когда добавляет новый URL-адрес в базу данных. Было бы неплохо, если бы они могли указать «id» URL-адреса во время создания, чтобы им было легче запомнить.

Обновите маршрут создания URL-адреса, чтобы пользователь мог передать параметр id. Если они не передадут его, для них должен быть сгенерирован случайный идентификатор.

Дополнительный кредит: если пользователь передает уже существующий идентификатор, сервер должен вернуть код состояния 409 (конфликт).

Упражнение 2.

В настоящее время нет возможности изменить или удалить короткие URL-адреса из базы данных. Как только вы его создадите, он останется там навсегда.

Реализуйте два новых маршрута: DELETE /urls/:id должен удалить соответствующий URL-адрес из базы данных, а PATCH /urls/:id позволяет пользователю обновлять существующую запись в базе данных, передавая данные JSON в теле запроса.

Упражнение 3 (пытайтесь, только если вы амбициозны)

В этой версии приложения нет понятия «собственность» или «контроль доступа». После того как вы закончите упражнение 2, любой желающий сможет обновить или удалить короткие URL-адреса других пользователей.

Разработайте и внедрите систему владения, чтобы пользователь мог обновлять или удалять только свои короткие URL-адреса. Они должны получить ответ 403 (запрещено), если они попытаются обновить короткий URL-адрес, который им не принадлежит.

Подсказка: библиотека buddy может быть полезна для аутентификации и авторизации.

Следующие шаги

Даже это простое приложение представило множество ключевых частей экосистемы Clojure. Однако есть и серьезные недостатки:

  1. Поскольку соединение с базой данных настраивается глобальной картой env, невозможно запустить тесты, требующие базы данных из REPL разработки, без повреждения вашей базы данных разработки. Вместо этого вы должны использовать lein test, который запускает полностью отдельный процесс Leiningen с другим профилем.
  2. Плагин lein-ring позволяет запускать сервер разработки с автоматической перезагрузкой. Однако REPL необходимо обновить вручную. Если вы забудете обновить REPL, состояние сервера разработки и REPL не будут синхронизированы друг с другом, что приведет к запутанному поведению.
  3. По умолчанию библиотека clojure.java.jdbc создает новое соединение с базой данных для каждого запроса. Это хорошо для разработки, но неэффективно для производства, когда ваш сервер будет обрабатывать множество одновременных запросов. Лучшим подходом было бы создать пул соединений, чтобы существующие соединения можно было повторно использовать для будущих запросов.
  4. Нет документации или правоприменения в отношении требуемой формы параметров функции и возвращаемых значений.

Наша конечная цель - создать более «готовую к работе» версию приложения Shortify, которая решает эти проблемы. Однако, прежде чем мы это сделаем, нам нужно будет изучить некоторые из более продвинутых возможностей Clojure. Далее: управление состоянием и параллелизм в Clojure.