СоХабр закрыт.
С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.
Я искал повода испробовать фреймворк Martini с момента его анонса в почтовой рассылке golang-nuts. Martini — это пакет (package) для языка программирования Go, предназначенный для веб-разработки. Он стремительно стартовал, заработав 2000 «звездочек» за несколько недель на GitHub (а впервые Martini был там опубликован около месяца назад) (прим. пер. статья-оригинал была опубликована 27 ноября 2013 года).
Поэтому я решил сделать пример приложения, который бы реализовывал некий (практичный) RESTful API, основанный на лучших практиках. Код, иллюстрирующий эту статью, можно посмотреть на GitHub.
net/http
из стандартной библиотеки, и факт понимания всесущего интерфейса http.Handler
(прим. пер.: видимо, автор имеет ввиду, что в стандартной библиотеке вовсю используется именно http.Handler
, а Martini ловко маскируется под него).inject
, таким же «худым», состоящим всего из ~100 строчек исходных текстов./albums
. И будет поддерживать следующий функционал:GET /albums
— список всех доступных альбомов, с возможной фильтрацией по исполнителю, названию или году с передачей параметров в строке запроса;GET /albums/:id
— получение конкретного альбома;POST /albums
— создание альбома;PUT /albums/:id
— обновление альбома;DELETE /albums/:id
— удаление альбома.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)
}
Id
устанавливается в XML как атрибут. Прочие теги просто представляют собой имя поля в нижнем регистре для сериализации. Простой текстовый формат будет использовать fmt.Sprintf
(прим. пер. в оригинале опечатка fmt.Stringer
) — что реализуется методом-функцией func String() string
.martini.Martini
, который реализует интерфейс http.Handler
, и, следовательно, переменная типа martini.Martini
может быть передана в вызов http.ListenAndServe()
как самый обычный обработчик, ожидаемый стандартной библиотекой. Другим важным понятием является то, что Martini использует подход, основанный на множестве слоев промежуточной обработки (прим. пер.: ориг. «middleware»). Значит, вы можете настроить список функций, подлежащих вызову в определенном порядке, до того, как будет вызван обработчик, обслуживающий конкретный URI. Это весьма удобно для использования настройки таких вещей как ведение логов, аутентификация, управление сессиям и т.д., и позволяет сохранять исходные тексты «СУХИМИ» (прим. пер. в ориг. игра слов с аббревиатурой «DRY», про которую я написал абзацем выше).martini.Classic()
, которая создает экземпляр (прим. пер.: имеется ввиду экземпляр типа martini.Martini) с разумными настройками по умолчанию — с общеупотребимыми промежуточными обработчиками: такими как перехват состояния panic с помощью recovery (прим. пер.: panic и recovery — это механизмы в языке Go, подобные используемым во многих других языках механизмам вызова/возбуждения исключений и их перехвата/обработки), ведения логов и поддержки статических файлов. Это здорово для веб-сайтов, но не нужно для API. Так как нам не нужно заботиться об обслуживании статических страниц, то мы не будем использовать «классический мартини».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)
}
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").m.MapTo()
привязывает переменную db
, объявленную в пакете (это экземпляр нашей базы данных в оперативной памяти), к интерфейсу DB
, который был определен ранее. В данном конкретном случае мы не получаем никаких преимуществ по сравнению с использованием потоко-безопасной (прим. пер.: ориг. «thread-safe») глобальной для пакета переменной db
напрямую, но в иных случаях (таких, как использование «перекодировщика», см. ниже) это может оказаться весьма полезным.nil
к типу pointer-to-DB-interface потому, что все, что нужно знать механизму внедрения зависимостей — это тип, привязываемый к первому параметру.m.Action()
добавляет подготовленный ранее список обработчиков URI, которые Martini может вызывать.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 в соответствующем формате.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 вместе с сообщением об ошибке.http.ResponseWriter
. Если же первое значение не является целым числом или будет возвращено только одно значение, то первое (или единственное) значение будет записано в http.ResponseWriter
.$ 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: 68
Slayer — Reign In Blood (1986)
Slayer — Seasons In The Abyss (1990)
$ 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}
$ 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
$ 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
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)
}
}
комментарии (9)