Я искал повода испробовать фреймворк Martini с момента его анонса в почтовой рассылке golang-nuts. Martini — это пакет (package) для языка программирования Go, предназначенный для веб-разработки. Он стремительно стартовал, заработав 2000 «звездочек» за несколько недель на GitHub (а впервые Martini был там опубликован около месяца назад) (прим. пер. статья-оригинал была опубликована 27 ноября 2013 года).
Поэтому я решил сделать пример приложения, который бы реализовывал некий (практичный) RESTful API, основанный на лучших практиках. Код, иллюстрирующий эту статью, можно посмотреть на GitHub.
Почему Martini?
В Martini есть много вещей для того, чтобы реализовать мою задумку.
Прежде всего, это очень элегантный API, использующий только тонкий слой абстракции поверх превосходного пакета net/http
из стандартной библиотеки, и факт понимания всесущего интерфейса http.Handler
(прим. пер.: видимо, автор имеет ввиду, что в стандартной библиотеке вовсю используется именно http.Handler
, а Martini ловко маскируется под него).
Другой ключевой особенностью является то, что как только Martini покажется для вас «магическим» (мне не нравится магия), вам абсолютно необходимо будет взглянуть на его исходные тексты. Это ~400 строчек исходных текстов, небольшие числом и хорошо контролируемые (так это было сегодня утром), с одной внешней зависимостью, пакетом inject
, таким же «худым», состоящим всего из ~100 строчек исходных текстов.
Имейте ввиду, что сейчас Martini в активной разработке, и что некоторые примеры могут отказаться работать по мере внесения в него изменений. Я постараюсь поддерживать исходные тексты моих примеров в актуальном состоянии.
Опишем задачу
Пример приложения будет предоставлять доступ к одному-единственному ресурсу, музыкальным альбомам, по URI /albums
. И будет поддерживать следующий функционал:
-
GET /albums
— список всех доступных альбомов, с возможной фильтрацией по исполнителю, названию или году с передачей параметров в строке запроса; -
GET /albums/:id
— получение конкретного альбома; -
POST /albums
— создание альбома; -
PUT /albums/:id
— обновление альбома; -
DELETE /albums/:id
— удаление альбома.
Чтобы сделать интереснее, предусмотрим, чтобы ответы могли бы запрашиваться в форматах JSON, XML или в виде простого текста. Формат ответа будет определяться по окончанию (по «расширению»: .json, .xml или .text, по умолчанию JSON).
Поскольку реализация подсистемы хранения данных не относится к целям создания приложения, то будем использовать управляемый через mutex ассоциативный массив пар «ключ-значение» (прим. пер.: в ориг. "(read-write) mutex-controlled map"), который будем использовать как базу данных в оперативной памяти, и реализуем это через следующий интерфейс:
type DB interface {
Get(id int) *Album
GetAll() []*Album
Find(band, title string, year int) []*Album
Add(a *Album) (int, error)
Update(a *Album) error
Delete(id int)
}
Определим структуру для хранения данных альбома так:
type Album struct {
XMLName xml.Name `json:"-" xml:"album"`
Id int `json:"id" xml:"id,attr"`
Band string `json:"band" xml:"band"`
Title string `json:"title" xml:"title"`
Year int `json:"year" xml:"year"`
}
func (a *Album) String() string {
return fmt.Sprintf("%s - %s (%d)", a.Band, a.Title, a.Year)
}
Теги полей управляют преобразованием (прим. пер. в ориг. «marshaling») структуры в JSON и XML. Поле XMLName — это имя корневого элемента в XML и не рассматривается в JSON. Идентификатор Id
устанавливается в XML как атрибут. Прочие теги просто представляют собой имя поля в нижнем регистре для сериализации. Простой текстовый формат будет использовать fmt.Sprintf
(прим. пер. в оригинале опечатка fmt.Stringer
) — что реализуется методом-функцией func String() string
.
Теперь посмотрим как с этим можно использовать Martini.
Сухой мартини
(прим. пер. — игра слов «Dry Martini» с одной стороны отсылка к названию известного сухого вермута «Martini Extra Dry», с другой стороны отсылка к известному принципу разработки ПО DRY — Don’t Repeat Yourself, при том, что слово «dry» в английском языке означает «сухой»)
Сердцем пакета martini является тип martini.Martini
, который реализует интерфейс http.Handler
, и, следовательно, переменная типа martini.Martini
может быть передана в вызов http.ListenAndServe()
как самый обычный обработчик, ожидаемый стандартной библиотекой. Другим важным понятием является то, что Martini использует подход, основанный на множестве слоев промежуточной обработки (прим. пер.: ориг. «middleware»). Значит, вы можете настроить список функций, подлежащих вызову в определенном порядке, до того, как будет вызван обработчик, обслуживающий конкретный URI. Это весьма удобно для использования настройки таких вещей как ведение логов, аутентификация, управление сессиям и т.д., и позволяет сохранять исходные тексты «СУХИМИ» (прим. пер. в ориг. игра слов с аббревиатурой «DRY», про которую я написал абзацем выше).
Пакет содержит функцию martini.Classic()
, которая создает экземпляр (прим. пер.: имеется ввиду экземпляр типа martini.Martini) с разумными настройками по умолчанию — с общеупотребимыми промежуточными обработчиками: такими как перехват состояния panic с помощью recovery (прим. пер.: panic и recovery — это механизмы в языке Go, подобные используемым во многих других языках механизмам вызова/возбуждения исключений и их перехвата/обработки), ведения логов и поддержки статических файлов. Это здорово для веб-сайтов, но не нужно для API. Так как нам не нужно заботиться об обслуживании статических страниц, то мы не будем использовать «классический мартини».
К счастью, это всего лишь удобная функция, у нас всегда есть возможность создать пустой экземпляр Martini и настроить его вручную так, как необходимо. Наша версия выглядит так:
var m *martini.Martini
func init() {
m = martini.New()
// Настройка "middleware"
m.Use(martini.Recovery())
m.Use(martini.Logger())
m.Use(auth.Basic(AuthToken, ""))
m.Use(MapEncoder)
// Настройка обработчиков URI
r := martini.NewRouter()
r.Get(`/albums`, GetAlbums)
r.Get(`/albums/:id`, GetAlbum)
r.Post(`/albums`, AddAlbum)
r.Put(`/albums/:id`, UpdateAlbum)
r.Delete(`/albums/:id`, DeleteAlbum)
// Внедрим в Martini базу данных (прим. пер.: ориг. "Inject database")
m.MapTo(db, (*DB)(nil))
// Передаем Martini информацию о назначенных выше обработчиках URI
m.Action(r.Handle)
}
Промежуточные обработчики для перехвата panic и ведения лога являются очевидными. auth.Basic()
— это промежуточный обработчик, который обеспечивает аутентификацию, его можно найти в martini-contrib (прим. пер.: автор статьи имеет ввиду https://github.com/codegangsta/martini-contrib/ ). auth.Basic()
слишком наивен для полноценной реализации, поскольку мы можем «скормить» ему одну-единственную пару «имя пользователя/пароль», и все запросы будут проверяться только по этим данным. В более реалистичном сценарии использования нам было бы необходимо поддерживать любое количество пар «имя пользователя/пароль» (прим. пер. ориг. «any number of valid access tokens»), чего этот простейший обработчик авторизации не умеет.
Пропустим пока промежуточный обработчик MapEncoder
, вернемся к нему через минуту. Следующий шаг — это настройка обработчиков URI (прим. пер.: ориг. «routes»). Martini обеспечивает хороший чистый способ сделать это: он поддерживает метки параметров (прим. пер.: ориг. «placeholders», имеется ввиду часть URI, отмеченная «двоеточием»), и, более того, вы можете использовать некоторые регулярные выражения (прим. пер.: ориг. «you can even throw some regular expressions in there, that’s how the path will end up anyway»). Вторым параметром в Get/Post/Put/пр.
является фунция-обработчик, которая будет вызвана для обслуживания данного URI. На обслуживание одного и того же URI могут быть назначены несколько функций-обработчиков. Они будут выполнены все по очереди. Их вызов в этой очереди будет прекращен как только какой-нибудь один из них выдаст ответ на запрос (прим. пер.: ориг. " (this is a variadic parameter), and they will be executed in order, until one of them writes a response").
Наверняка, читатель сталкивался с самой техникой использования паттерна «внедрение зависимости», ведь техника эта проста и часто используется. Но, возможно, читатель не знаком с самим термином «внедрение зависимости». Чтобы понять какую простую вещь понимают под этим термином, можно почитать на русском языке следующие статьи:
Затем мы определим «глобальную зависимость». Это весьма полезная особенность Martini (это не приглядно? :-) (прим. пер.: автор статьи намекает на обсуждение этой особенности Martini в Twitter, ориг. «wait, or is it icky»). Поддерживаются зависимости с глобальной областью действия и зависимости уровня запроса. Если при обработке запроса вызывается какая-то функция (неважно — промежуточный ли это обработчик или обработчик URI), принимающая параметр типа, который соответствует типу зависимости, то такой функции механизм внедрения зависимостей «скормит» правильное значение. В нашем случае m.MapTo()
привязывает переменную db
, объявленную в пакете (это экземпляр нашей базы данных в оперативной памяти), к интерфейсу DB
, который был определен ранее. В данном конкретном случае мы не получаем никаких преимуществ по сравнению с использованием потоко-безопасной (прим. пер.: ориг. «thread-safe») глобальной для пакета переменной db
напрямую, но в иных случаях (таких, как использование «перекодировщика», см. ниже) это может оказаться весьма полезным.
Синтаксис второго параметра может показаться странным. Мы приводим nil
к типу pointer-to-DB-interface потому, что все, что нужно знать механизму внедрения зависимостей — это тип, привязываемый к первому параметру.
Финальный шаг: m.Action()
добавляет подготовленный ранее список обработчиков URI, которые Martini может вызывать.
Промежуточный обработчик MapEncoder
Вернемся назад к промежуточному обработчику MapEncoder
. Его задача внедрить в текущий запрос реализацию интерфейса Encoder
(прим. пер.: используется вся та же технология «внедрения зависимости», что и выше использовалась для внедрения в Martini базы данных), которая соответствует запрашиваемому формату ответа:
// Encoder реализует преобразование в некий формат значений, отправляемых
// в качестве ответа на запрос конечному пользователю нашего API
// (прим. пер.: ориг. "on the API endpoints.")
//
type Encoder interface {
Encode(v ...interface{}) (string, error)
}
// С помощью регулярного выражения определяем требуемый формат
// (в конце URI допустим слеш).
//
var rxExt = regexp.MustCompile(`(.(?:xml|text|json))/?$`)
// MapEncoder перехватывает URL запроса, определяет запрашиваемый формат
// и внедряет верную зависимость-кодировщик формата для этого запроса.
// После чего переписывает URL для удаления из него указания на формат,
// так что подходящий обработчик URI будет выбран уже без учета формата.
//
func MapEncoder(c martini.Context, w http.ResponseWriter, r *http.Request) {
// Определяем формат по окончанию URI, подобному расширению в именах файлов
//
matches := rxExt.FindStringSubmatch(r.URL.Path)
ft := ".json"
if len(matches) > 1 {
// Переписываем URL на URL без указания формата
//
l := len(r.URL.Path) - len(matches[1])
if strings.HasSuffix(r.URL.Path, "/") {
l--
}
r.URL.Path = r.URL.Path[:l]
ft = matches[1]
}
// Встраиваем подходящий кодировщик формата
//
switch ft {
case ".xml":
c.MapTo(xmlEncoder{}, (*Encoder)(nil))
w.Header().Set("Content-Type", "application/xml")
case ".text":
c.MapTo(textEncoder{}, (*Encoder)(nil))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
default:
c.MapTo(jsonEncoder{}, (*Encoder)(nil))
w.Header().Set("Content-Type", "application/json")
}
}
Здесь функция MapTo()
вызывается для martini.Context
, это означает, что встроенная зависимость будет видима только в пределах одного конкретного запроса. Кроме того, здесь же выставляется корректный заголовок “Content-Type”, следовательно, начиная с этого момента в том числе и ошибки будут возвращены из запроса конечному пользователю нашего API в соответствующем формате.
Обработчики URI
Я не буду вдаваться в подробности относительно всех обработчиков URI (с ними со всеми можно ознакомиться, посмотрев в файл api.go
, являющегося частью этого примера приложения), но я покажу один, чтобы поговорить о том, как Martini обрабатывает возвращаемые значения. Обработчик GET для одного альбома выглядет так:
func GetAlbum(enc Encoder, db DB, parms martini.Params) (int, string) {
id, err := strconv.Atoi(parms["id"])
al := db.Get(id)
if err != nil || al == nil {
return http.StatusNotFound, Must(enc.Encode(
NewError(ErrCodeNotExist, fmt.Sprintf("the album with id %s does not exist", parms["id"]))))
}
return http.StatusOK, Must(enc.Encode(al))
}
Для начала, мы видим, что martini.Params
может быть использован для получения параметров, определенных при настройках обработчиков URI, в виде массива. Если id не является целым числом или если этот id не существует в базе данных (а я знаю, что фактически в базе данных нет id=0, поэтому использую такой id по двойному назначению), то будет возвращен код состояния http 404 с корректно закодированным сообщением об ошибке. Обратите внимание на использование вызова Must()
— так как у нас есть промежуточный обработчик Recovery()
, он поймает состояние panic языка Go, которое может быть инициировано вызовом Must()
(прим. пер. в ориг. написано намного проще: «trap panics»), и вернет 500. Таким образом, мы можем безопасно вызывать состояние panic в случае ошибок на стороне сервера. Хотя более серьёзные проекты хотели бы вернуть код состояния 500 вместе с сообщением об ошибке.
И наконец, если всё пойдет хорошо, будет возвращен код состояния 200 вместе с закодированным содержимым альбома. Если обработчик URI возвращает 2 значения, и первое значение является целым числом, то Martini будет использовать первое значение как код состояния и запишет второе значение как строку в http.ResponseWriter
. Если же первое значение не является целым числом или будет возвращено только одно значение, то первое (или единственное) значение будет записано в http.ResponseWriter
.
Вызовы curl
Давайте посмотрим как наше API будет работать с реальными вызовами:
$ curl -i -k -u token: «localhost:8001/albums»
HTTP/1.1 200 OK
Content-Type: application/json
Date: Wed, 27 Nov 2013 02:31:46 GMT
Content-Length: 201[{«id»:1,«band»:«Slayer»,«title»:«Reign In Blood»,«year»:1986},{«id»:2,«band»:«Slayer»,«title»:«Seasons In The Abyss»,«year»:1990},{«id»:3,«band»:«Bruce Springsteen»,«title»:«Born To Run»,«year»:1975}]
Параметр -k
требуется, если вы используете самоподписанные сертификаты. С помощью параметра -u
передается имя пользователя и пароль, который в нашем случае представляет из себя простой token:
(пользователь «token» и пустой пароль). Параметр -i нужен для вывода полного ответа, включая заголовки. Ответ включает полный список альбомов (база данных инициализируется с этими 3-мя альбомами).
$ curl -i -k -u token: «localhost:8001/albums.text?band=Slayer»
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Wed, 27 Nov 2013 02:36:46 GMT
Content-Length: 68Slayer — Reign In Blood (1986)
Slayer — Seasons In The Abyss (1990)
В этом случае запрошен текстовый формат и используется фильтр по исполнителю Slayer. Давайте попробуем запрос POST:
$ curl -i -k -u token: -X POST --data «band=Carcass&title=Heartwork&year=1994» «localhost:8001/albums»
HTTP/1.1 201 Created
Content-Type: application/json
Location: /albums/4
Date: Wed, 27 Nov 2013 02:38:55 GMT
Content-Length: 57{«id»:4,«band»:«Carcass»,«title»:«Heartwork»,«year»:1994}
Код состояния 201 — «Создано». Заголовок “Location” содержит URL с созданным новым ресурсом (обратите внимание, что URL должен быть абсолютным, я тут поленился), и ресурс возвращается в формате по умолчанию (JSON). Попытаемся создать тот же самый ресурс снова, для разнообразия в формате XML:
$ curl -i -k -u token: -X POST --data «band=Carcass&title=Heartwork&year=1994» «localhost:8001/albums.xml»
HTTP/1.1 409 Conflict
Content-Type: application/xml
Date: Wed, 27 Nov 2013 02:41:36 GMT
Content-Length: 171<?xml version=«1.0» encoding=«UTF-8»?>
the album 'Heartwork' from 'Carcass' already exists
Ошибка возвращается в корректном формате с кодом статуса 409. Обновления (PUT) тоже нормально работают (надеюсь, все в курсе, что альбом Heartwork был выпущен 1993 году?):
$ curl -i -k -u token: -X PUT --data «band=Carcass&title=Heartwork&year=1993» «localhost:8001/albums/4»
HTTP/1.1 200 OK
Content-Type: application/json
Date: Wed, 27 Nov 2013 02:45:29 GMT
Content-Length: 57{«id»:4,«band»:«Carcass»,«title»:«Heartwork»,«year»:1993}
И, наконец, операция удаления:
$ curl -i -k -u token: -X DELETE «localhost:8001/albums/1»
HTTP/1.1 204 No Content
Content-Type: application/json
Date: Wed, 27 Nov 2013 02:46:59 GMT
Content-Length: 0
Требуется https
Вы же не хотите предоставлять доступ к API аутентификации (или к любому другому общему публичному API) через http. И потому рекомендуется возвращать ошибку в случае вызова API чистым текстом (прим. пер.: автор имеет виду простой текстовый протокол http), вместо того, чтобы молча перенаправлять вызов на https, тогда конечный пользователь API сможет обратить внимание на проблему. Существует множество способов реализовать такое. Это хорошо было бы сделать на обратном прокси-сервере, если таковой был бы перед вашим сервером API. В этом примере приложения я запускаю два сервера (прим. пер. ориг.: «I start two listeners»), один для работы с http
и другой для работы с https
. И сервер http
всегда возвращает ошибку:
func main() {
go func() {
// Слушаем http, возвращаем ошибку и уведомление, что требуется https
if err := http.ListenAndServe(":8000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "https scheme is required", http.StatusBadRequest)
})); err != nil {
log.Fatal(err)
}
}()
// Слушаем https, используем предварительно настроенный экземпляр Martini.
// Файлы сертификатов могут быть созданы с использованием следующей команды
// в корневом каталоге веб-приложения:
//
// go run /path/to/goroot/src/pkg/crypto/tls/generate_cert.go --host="localhost"
//
if err := http.ListenAndServeTLS(":8001", "cert.pem", "key.pem", m); err != nil {
log.Fatal(err)
}
}
Чего не хватает
Это простой пример приложения API, но это он уже содержит обработчики многих общих задач API. Martini делает это легким и элегантным благодаря своим механизмам обработки URI и внедрению зависимостей. За то небольшое время, что я писал эту статью, Martini развивался, в частности некоторые обработчики были добавлены в martini-contrib.
Если вы намерены построить полноценный пригодный для серьёзной эксплуатации API, то имейте ввиду, что в этом простом примере приложения отсутствует несколько важных вещей, а именно:
Очевидно, более мощный механизм аутентификации (не по одному же маркеру доступа!)
Поддержка кодирования тел запросов в JSON (и/или XML) для операций POST и PUT (и PATCH)
Поддержка 405 — Метод не поддерживается (сейчас API будет возвращать 404, когда неподдерживаемый метод используется для поддерживаемого URI)
Поддержка сжатия ответов с помощью GZIP (я тут смотрю — сейчас есть в martini-contrib реализация промежуточного обработчика для GZIP)
И наверняка, много еще чего, в зависимости от ваших требований!
Тем не менее, это статья должна была помочь вам почувствовать вкус к Martini.
Автор: denisgorbunov