Created
March 17, 2014 01:59
-
-
Save m0sth8/9592678 to your computer and use it in GitHub Desktop.
How to build web application on Go (Part 1)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
В этой статье, я хотел бы рассказать вам, как можно достаточно быстро и легко написать небольшое веб-приложение на языке Go, который, не смотря на юный возраст, успел завоевать расположение у многих разработчиков. Обычно, для подобных статей пишут искусственные приложения, вроде TODO листа. Мы же попробуем написать что-то полезное, что уже существует и используется. | |
Часто, при разработке сервисов, нужно понимать какие данные отправляются в другой сервис, а возможность перехватить траффик есть не всегда. И как раз для того, чтобы отлавливать подобные запросы, существует проект http://requestb.in/, позволяющий собирать запросы по определённому урлу и отображать их в веб-интерфейсе. Написанием подобного же приложения мы и займёмся. Чтобы немного упростить себе задачу, возьмём за основу какой-нибудь фреймворк, например <a href="http://martini.codegangsta.io/">Martini</a>. | |
В конечном итоге, у нас должен будет получится вот такой вот сервис: | |
<img align="left" src="http://habrastorage.org/getpro/habr/post_images/a18/13a/db9/a1813adb9fcbbd5f30549ad4d0429b8f.png"/> | |
<habracut text="Приступим к разработке" /> | |
<h4>Подготовка</h4> | |
Эта статья будет разделена на шаги, каждый из которых будет содержать код, хранящийся в отдельной ветке репозитория на GitHub. Вы всегда сможете запустить и посмотреть результаты, а так же поиграться с кодом. | |
Для запуска приложения нужно иметь на своей машине компилятор Go. Я исхожу из предположения, что он у вас уже есть и настроен так, как вам удобно. Если же нет, то узнать как это сделать вы можете <a href="http://golang.org/doc/install">на странице проекта</a>. | |
В качестве среды для разработки, вы можете использовать то, что вам удобнее, благо, плагины для Go есть почти под каждый редактор. Наиболее популярнен <a href="https://github.com/DisposaBoy/GoSublime">GoSublime</a>. Но я бы посоветовал IntelijIdea + <a href="http://plugins.jetbrains.com/plugin/5047">go-lang-ide-plugin</a>, который последнее время очень активно развивается, например из последнего добавленного - дебаг приложения. | |
Попробовать уже готовый сервис в работе можно по ссылке http://skimmer.tulu.la/. | |
Для начала работы нужно склонировать репозиторий к себе на машину в какую-нибудь директорию, например так: | |
<source lang="bash"> | |
git clone https://github.com/m0sth8/skimmer ./skimmer | |
</source> | |
Вы можете добавить проект в своё рабочее окружение (подробнее об этом можно прочитать на <a href="http://golang.org/doc/code.html#Workspaces">сайте проекта</a>) , либо организовывать код, как вам удобно. Я же для простоты изложения, использую <a href="https://github.com/pwoolcoc/goenv">goenv</a>, позволяющий указывать версии компилятора go и создавать чистое рабочее окружение в директории проекта. | |
Теперь нам нужно зайти в склонированную директорию skimmer и установить нужные зависимости командой: | |
<source lang="bash"> | |
go get -d ./src/ | |
</source> | |
После завершения установки зависимости, можно запустить проект: | |
<source lang="bash"> | |
go run ./src/main.go | |
</source> | |
У вас должен запуститься веб-сервис на порту 3000 (порт и хост можно указать через переменные окружения PORT и HOST соответственно). Теперь можно открыть его в браузере по адресу 127.0.0.1:3000 и попробовать уже готовый сервис в работе. | |
Впереди нас ждут следующие этапы: | |
<ol> | |
<li>Шаг первый. Знакомство с Martini;</li> | |
<li>Шаг второй. Создаём модель Bin и отвечаем на запросы;</li> | |
<li>Шаг третий. Принимаем запросы и сохраняем их в хранилище;</li> | |
<li>Шаг четвёртый. А как же тесты?</li> | |
<li>Шаг пятый— украшательства и веб-интерфейс;</li> | |
<li>Шаг шестой. Добавляем немного приватности;</li> | |
<li>Шаг седьмой. Очищаем ненужное;</li> | |
<li>Шаг восьмой. Используем Redis для хранения.</li> | |
</ol> | |
Особая благодарность <hh user="kavu"/> за коррекцию первой и второй части статьи. | |
Приступим к разработке. | |
<h4>Шаг первый. Знакомство с Martini.</h4> | |
Загрузим код первого шага: | |
<source code="bash"> | |
git checkout step-1 | |
</source> | |
Для начала попробуем просто вывести запрос, приходящий к нам. Точка входа в любое приложение на Go, это функция main пакета main. Создадим в директории src файл main.go. В Martini уже есть заготовка приложения, добавляющая логи, обработку ошибок, возможность восстановления и роутер; и дабы не повторяться, мы воспользуемся ей. | |
Сам по себе Martini достаточно прост: | |
<source lang="go"> | |
// Martini represents the top level web application. inject.Injector methods can be invoked to map services on a global level. | |
type Martini struct { | |
inject.Injector | |
handlers []Handler | |
action Handler | |
logger *log.Logger | |
} | |
</source> | |
Он реализует интерфейс <a href="http://golang.org/pkg/net/http/#Handler">http.Handler</a>, имплементируя метод ServeHTTP. Далее все приходящие запросы пропускаются через различные обработчики, хранящиеся в handlers и в конце выполняет Handler action. | |
Классический Martini: | |
<source lang="go"> | |
// Classic creates a classic Martini with some basic default middleware - martini.Logger, martini.Recovery, and martini.Static. | |
func Classic() *ClassicMartini { | |
r := NewRouter() | |
m := New() | |
m.Use(Logger()) | |
m.Use(Recovery()) | |
m.Use(Static("public")) | |
m.Action(r.Handle) | |
return &ClassicMartini{m, r} | |
} | |
</source> | |
В этом конструкторе создаётся объект типа Martini и Router, в обработчики handler через метод martini.Use добавляется логирование запросов, перехват panic (<a href="http://blog.golang.org/defer-panic-and-recover">подробнее</a> об этом механизме), отдача статики, и последним действием устанавливается обработчик роутера. | |
Мы будем перехватывать любые HTTP запросы к нашему приложению, используя метод <code>Any</code> у роутера, перехватывающий любые урлы и методы. Интерфейс роутера описан в Martini вот так: | |
<source lang="go"> | |
type Router interface { | |
// Get adds a route for a HTTP GET request to the specified matching pattern. | |
Get(string, ...Handler) Route | |
// Patch adds a route for a HTTP PATCH request to the specified matching pattern. | |
Patch(string, ...Handler) Route | |
// Post adds a route for a HTTP POST request to the specified matching pattern. | |
Post(string, ...Handler) Route | |
// Put adds a route for a HTTP PUT request to the specified matching pattern. | |
Put(string, ...Handler) Route | |
// Delete adds a route for a HTTP DELETE request to the specified matching pattern. | |
Delete(string, ...Handler) Route | |
// Options adds a route for a HTTP OPTIONS request to the specified matching pattern. | |
Options(string, ...Handler) Route | |
// Any adds a route for any HTTP method request to the specified matching pattern. | |
Any(string, ...Handler) Route | |
// NotFound sets the handlers that are called when a no route matches a request. Throws a basic 404 by default. | |
NotFound(...Handler) | |
// Handle is the entry point for routing. This is used as a martini.Handler | |
Handle(http.ResponseWriter, *http.Request, Context) | |
} | |
</source> | |
Если очень хочется - можно реализовать свою имплементацию обработчика адресов, но мы воспользуемся той, что идет в Martini по умолчанию. | |
Первым параметром указывается локейшен. Локейшены в Martini поддерживают параметры через <code>":param"</code>, регулярные выражения, а так же <a href="http://en.wikipedia.org/wiki/Glob_(programming)">glob</a>. Второй параметр и последующие, принимают функцию, которая будет заниматься обработкой запроса. Так как Martini поддерживает цепочку обработчиков, сюда можно добавлять различные вспомогательные хендлеры, например проверку прав доступа. Нам пока это ни к чему, поэтому добавим только один обработчик c интерфейсом, обрабатываемым обычным веб обработчиком Go (пример разработки на нём можно посмотреть<a href="http://golang.org/doc/articles/wiki/"> в документации</a>). Вот код нашего обработчика: | |
<source lang="go"> | |
func main() { | |
api := martini.Classic() | |
api.Any("/", func(res http.ResponseWriter, req *http.Request,) { | |
if dumped, err := httputil.DumpRequest(req, true); err == nil { | |
res.WriteHeader(200) | |
res.Write(dumped) | |
} else { | |
res.WriteHeader(500) | |
fmt.Fprintf(res, "Error: %v", err) | |
} | |
}) | |
api.Run() | |
} | |
</source> | |
Используя готовую функцию <a href="http://golang.org/pkg/net/http/httputil/#DumpRequest">DumpRequest</a> из пакета <a href="http://golang.org/pkg/net/http/httputil/">httputil</a> мы сохраняем структуру запроса http.Request, и записываем его в ответ http.ResponseWriter. Так же не забываем обрабатывать возможные ошибки. Функция api.Run просто запускает встроенный сервер go из стандартной библиотеки, указывая порт и хост, которые она берёт из параметров окружения PORT(3000 по умолчанию) и HOST. | |
Запустим наше первое приложение: | |
<source lang="bash"> | |
go run ./src/main.go | |
</source> | |
Попробуем отправить запрос к серверу: | |
<source lang="bash"> | |
> curl -X POST -d "fizz=buzz" http://127.0.0.1:3000 | |
POST / HTTP/1.1 | |
Host: 127.0.0.1:3000 | |
Accept: */* | |
Content-Type: application/x-www-form-urlencoded | |
User-Agent: curl/7.24.0 (x86_64-apple-darwin12.0) libcurl/7.24.0 OpenSSL/0.9.8y zlib/1.2.5 | |
fizz=buzz | |
</source> | |
Это была всего лишь проба сил, теперь приступим к написанию настоящего приложения. | |
<h4>Шаг второй. Создаём модель Bin и отвечаем на запросы.</h4> | |
Не забываем загрузить код: | |
<source code="bash"> | |
git checkout step-2 | |
</source> | |
Размещать код внутри пакета main не очень правильно, так как, например <a href="https://developers.google.com/appengine/docs/go/">Google Application Engine</a> создаёт свой пакет main, в котором уже подключаются ваши. Поэтому вынесем создание API в отдельный модуль, назовём его, например skimmer/api.go. | |
Теперь нам нужно создать сущность, в которой мы сможем хранить пойманные запросы, назовём её Bin, по аналогии с requestbin. Моделью у нас будет просто обычная структура данных Go. <blockquote>Порядок полей в структуре достаточно важен, но мы не будем задумываться об этом, но те кто хотят узнать как порядок влияет на размер структуры в памяти, могут почитать вот эти статьи - http://www.goinggo.net/2013/07/understanding-type-in-go.html и http://www.geeksforgeeks.org/structure-member-alignment-padding-and-data-packing/.</blockquote> | |
Итак, наша модель Bin будет содержать поля с названием, количеством пойманных запросов, и датами создания и изменения. Каждое поле у нас так же описывается тэгом. | |
<blockquote>Тэги это обычные строки, которые никак не влияют на программу в целом, но их можно прочитать используя пакет reflection во время работы программы (так называемая интроспекция), и исходя из этого изменять своё поведение (о том как работать тэгами через <a href="http://golang.org/pkg/reflect/#StructTag">reflection</a>). В нашем примере, пакет json при кодировании/раскодировании учитывает значение тэга, примерно так: | |
<source lang="go"> | |
package main | |
import ( | |
"reflect" | |
"fmt" | |
) | |
type Bin struct { | |
Name string `json:"name"` | |
} | |
func main() { | |
bin := Bin{} | |
bt := reflect.TypeOf(bin) | |
field := bt.Field(0) | |
fmt.Printf("Field's '%s' json name is '%s'", field.Name, field.Tag.Get("json")) | |
} | |
</source> | |
Выведет | |
<code>Field's 'Name' json name is 'name' </code> | |
Пакет encoding/json поддерживает различные опции при формировании тэгов: | |
<source lang="go"> | |
// Поле игнорируется | |
Field int `json:"-"` | |
// В json структуре поле интерпретируется как myName | |
Field int `json:"myName"` | |
</source> | |
Вторым параметром может быть например, опция omitempty - если значение в json пропущено, то поле не заполняется. Так например, если поле будет ссылкой, мы сможем узнать, присутствует ли оно в json объекте, сравнив его с nil. Более подробно о json сериализации можно почитать в <a href="http://golang.org/pkg/encoding/json/ ">документации </a></blockquote> | |
Так же мы описываем вспомогательную функцию NewBin, в которой происходит инициализация значений объекта Bin (своего рода конструктор): | |
<source lang="go"> | |
type Bin struct { | |
Name string `json:"name"` | |
Created int64 `json:"created"` | |
Updated int64 `json:"updated"` | |
RequestCount int `json:"requestCount"` | |
} | |
func NewBin() *Bin { | |
now := time.Now().Unix() | |
bin := Bin{ | |
Created: now, | |
Updated: now, | |
Name: rs.Generate(6), | |
} | |
return &bin | |
} | |
</source> | |
<blockquote>Структуры в Go могут иницилизироваться двумя способами: | |
1) Обязательным перечислением всех полей по порядку: | |
<source lang="go"> | |
Bin{rs.Generate(6), now, now, 0} | |
</source> | |
2) Указанием полей, для которых присваиваются значения: | |
<source lang="go"> | |
Bin{ | |
Created: now, | |
Updated: now, | |
Name: rs.Generate(6), | |
} | |
</source> | |
Поля, которые не указаны, принимают значения по умолчанию. Например для целых чисел это будет 0, для строк - пустая строка "", для ссылок, каналов, массивов, слайсов и словарей - это будет nil. Подробнее в <a href="http://golang.org/ref/spec#The_zero_value">документации</a>. Главное помнить, что смешивать эти два типа инициализации нельзя.</blockquote> | |
Теперь более подробно про генерацию строк через объект rs. Он инициализирован следующим образом: | |
<source lang="go"> | |
var rs = NewRandomString("0123456789abcdefghijklmnopqrstuvwxyz") | |
</source> | |
Сам код находится в файле utils.go. В функцию мы передаём массив символов, из которых нужно генерировать строчку и создаём объект RandomString: | |
<source lang="go"> | |
type RandomString struct { | |
pool string | |
rg *rand.Rand | |
} | |
func NewRandomString(pool string) *RandomString { | |
return &RandomString{ | |
pool, | |
rand.New(rand.NewSource(time.Now().Unix())), | |
} | |
} | |
func (rs *RandomString) Generate(length int) (r string) { | |
if length < 1 { | |
return | |
} | |
b := make([]byte, length) | |
for i, _ := range b { | |
b[i] = rs.pool[rs.rg.Intn(len(rs.pool))] | |
} | |
r = string(b) | |
return | |
} | |
</source> | |
Здесь мы используем пакет <a href="http://golang.org/pkg/math/rand">math/rand</a>, предоставляющий нам доступ к генерации случайных чисел. Самое главное, посеять генератор перед началом работы с ним, чтобы у нас не получилась одинаковая последовательность случайных чисел при каждом запуске. | |
В методе Generate мы создаём массив байтов, и каждый из байтов заполняем случайным символом из строки pool. Получившуюся в итоге строку возвращаем. | |
Перейдём, собственно, к описанию Api. Для начала нам нужно три метода для работы с объектами типа Bin, вывода списка объектов, создание и получение конкретного объекта. | |
Ранее я писал, что martini принимает в обработчик функцию с интерфейсом HandlerFunc, на самом деле, принимаемая функция в Martini описывается как interface{} - то есть это может быть абсолютно любая функция. Каким же образом в эту функцию вставляются аргументы? Делается это при помощи известного паттерна - <a href="http://en.wikipedia.org/wiki/Dependency_injection">Dependency injection</a> (далее DI) при помощи небольшого пакета <a href="https://github.com/codegangsta/inject/ ">inject</a> от автора martini. Не буду вдаваться в подробности относительно того, как это сделано, вы можете посмотреть в код самостоятельно, благо он не большой и там всё довольно просто. Но если двумя словами, то при помощи уже упомянутого пакета reflect, получаются типы аргументов функции и после этого подставляются нужные объекты этого типа. Например когда inject видит тип *http.Request, он подставляет объект req *http.Request в этот параметр. | |
Мы можем сами добавлять нужные объекты для рефлексии через методы объекта Map и MapTo глобально, либо через объект контекста запроса martini.Context для каждого запроса отдельно. | |
Объявим временные переменные history и bins, первый будет содержать историю созданных нами объектов Bin, а второй будет некой куцей версией хранилища объектов Bin. | |
Теперь рассмотрим созданные методы. | |
<h5>Создание объекта Bin</h5> | |
<source lang="go"> | |
api.Post("/api/v1/bins/", func(r render.Render){ | |
bin := NewBin() | |
bins[bin.Name] = bin | |
history = append(history, bin.Name) | |
r.JSON(http.StatusCreated, bin) | |
}) | |
</source> | |
<h5>Получение списка объектов Bin</h5> | |
<source lang="go"> | |
api.Get("/api/v1/bins/", func(r render.Render){ | |
filteredBins := []*Bin{} | |
for _, name := range(history) { | |
if bin, ok := bins[name]; ok { | |
filteredBins = append(filteredBins, bin) | |
} | |
} | |
r.JSON(http.StatusOK, filteredBins) | |
}) | |
</source> | |
<h5>Получение конкретного экземпляра</h5> | |
<source lang="go"> | |
api.Get("/api/v1/bins/:bin", func(r render.Render, params martini.Params){ | |
if bin, ok := bins[params["bin"]]; ok{ | |
r.JSON(http.StatusOK, bin) | |
} else { | |
r.Error(http.StatusNotFound) | |
} | |
}) | |
</source> | |
Метод позволяющий получить объект Bin по его имени, в нём мы используем объект martini.Params (по сути просто map[string]string), через который можем доступиться к разобранным параметрам адреса. | |
<blockquote>В языке Go мы можем обратиться к элементу словаря двумя способами: | |
<ol> | |
<li>Запросив значение ключа <code>a := m[key]</code>, в этом случае вернётся либо значение ключа в словаре, если оно есть, либо дефолтное значение инициализации типа значения. Таким образом, например для чисел, сложно понять, содержит ли ключ 0 или просто значения этого ключа не существует. Поэтому в го предусмотрен второй вариант.</li> | |
<li>В этом способе, запросив по ключу и получить его значение первым параметром и индикатор существования этого ключа вторым параметром — <code>a, ok := m[key]</code></li> | |
</ol> | |
</blockquote> | |
Поэкспериментируем с нашим приложением. Для начала запустим его: | |
<source code="bash"> | |
go run ./src/main.go | |
</source> | |
Добавим новый объект Bin: | |
<source code="bash"> | |
> curl -i -X POST "127.0.0.1:3000/api/v1/bins/" | |
HTTP/1.1 201 Created | |
Content-Type: application/json; charset=UTF-8 | |
Date: Mon, 03 Mar 2014 04:10:38 GMT | |
Content-Length: 76 | |
{"name":"7xpogf","created":1393819838,"updated":1393819838,"requestCount":0} | |
</source> | |
Получим список доступных нам Bin объектов: | |
<source code="bash"> | |
> curl -i "127.0.0.1:3000/api/v1/bins/" | |
HTTP/1.1 200 OK | |
Content-Type: application/json; charset=UTF-8 | |
Date: Mon, 03 Mar 2014 04:11:18 GMT | |
Content-Length: 78 | |
[{"name":"7xpogf","created":1393819838,"updated":1393819838,"requestCount":0}] | |
</source> | |
Запросим конкретный объект Bin, взяв значение name из предыдущего запроса: | |
<source code="bash"> | |
curl -i "127.0.0.1:3000/api/v1/bins/7xpogf" | |
HTTP/1.1 200 OK | |
Content-Type: application/json; charset=UTF-8 | |
Date: Mon, 03 Mar 2014 04:12:13 GMT | |
Content-Length: 76 | |
{"name":"7xpogf","created":1393819838,"updated":1393819838,"requestCount":0} | |
</source> | |
Отлично, теперь мы научились создавать модели и отвечать на запросы, кажется теперь нас ничего не удержит от того, чтобы доделать всё остальное. | |
<h4>Шаг третий. Принимаем запросы и сохраняем их в хранилище.</h4> | |
Теперь нам нужно научиться сохранять запросы, приходящие к нам, в нужный объект Bin. | |
Загрузим код для третьего шага | |
<source code="bash"> | |
git checkout step-3 | |
</source> | |
<h5>Модель Request</h5> | |
Для начала создадим модель, которая будет хранить в себе HTTP запрос. | |
<source code="go"> | |
type Request struct { | |
Id string `json:"id"` | |
Created int64 `json:"created"` | |
Method string `json:"method"` // GET, POST, PUT, etc. | |
Proto string `json:"proto"` // "HTTP/1.0" | |
Header http.Header `json:"header"` | |
ContentLength int64 `json:"contentLength"` | |
RemoteAddr string `json:"remoteAddr"` | |
Host string `json:"host"` | |
RequestURI string `json:"requestURI"` | |
Body string `json:"body"` | |
FormValue map[string][]string `json:"formValue"` | |
FormFile []string `json:"formFile"` | |
} | |
</source> | |
Объяснять какое поле для чего нужно, полагаю смысла нет, но есть пара замечаний: для файлов мы будем хранить только их названия, а для данных формы — будем хранить уже готовый словарь значений. | |
По аналогии с созданием объекта Bin, напишем функцию создающую объект Request из HTTP запроса: | |
<source code="go"> | |
func NewRequest(httpRequest *http.Request, maxBodySize int) *Request { | |
var ( | |
bodyValue string | |
formValue map[string][]string | |
formFile []string | |
) | |
// Считываем тело приходящего запроса из буфера и подменяем исходный буфер на новый | |
if body, err := ioutil.ReadAll(httpRequest.Body); err == nil { | |
if len(body) > 0 && maxBodySize != 0 { | |
if maxBodySize == -1 || httpRequest.ContentLength < int64(maxBodySize) { | |
bodyValue = string(body) | |
} else { | |
bodyValue = fmt.Sprintf("%s\n<<<TRUNCATED , %d of %d", string(body[0:maxBodySize]), | |
maxBodySize, httpRequest.ContentLength) | |
} | |
} | |
httpRequest.Body = ioutil.NopCloser(bytes.NewBuffer(body)) | |
defer httpRequest.Body.Close() | |
} | |
httpRequest.ParseMultipartForm(0) | |
if httpRequest.MultipartForm != nil { | |
formValue = httpRequest.MultipartForm.Value | |
for key := range httpRequest.MultipartForm.File { | |
formFile = append(formFile, key) | |
} | |
} else { | |
formValue = httpRequest.PostForm | |
} | |
request := Request{ | |
Id: rs.Generate(12), | |
Created: time.Now().Unix(), | |
Method: httpRequest.Method, | |
Proto: httpRequest.Proto, | |
Host: httpRequest.Host, | |
Header: httpRequest.Header, | |
ContentLength: httpRequest.ContentLength, | |
RemoteAddr: httpRequest.RemoteAddr, | |
RequestURI: httpRequest.RequestURI, | |
FormValue: formValue, | |
FormFile: formFile, | |
Body: bodyValue, | |
} | |
return &request | |
} | |
</source> | |
Функция получилась достаточно большой, но в целом, понятной, поясню только некоторые моменты. В объекте <a href="http://golang.org/pkg/net/http/#Request">http.Request</a>, тело запроса - Body это некий буффер, реализующий интерфейс <a href="http://golang.org/pkg/io/#ReadCloser">io.ReadCloser</a>, по этой причине после разбора формы (вызов метода ParseMultipartForm), мы уже никак не сможем получить сырые данные запроса. Поэтому для начала мы копируем Body в отдельную переменную и после заменим исходный буфер своим. Далее мы вызываем разбор входящих данных и собираем информацию о значениях форм и файлов. | |
Помимо объектов Bin, теперь нам нужно так же хранить и запросы, поэтому, пришло время добавить в наш проект возможность хранения данных. Опишем его интерфейс в файле storage.go: | |
<source code="go"> | |
type Storage interface { | |
LookupBin(name string) (*Bin, error) // get one bin element by name | |
LookupBins(names []string) ([]*Bin, error) // get slice of bin elements | |
LookupRequest(binName, id string) (*Request, error) // get request from bin by id | |
LookupRequests(binName string, from, to int) ([]*Request, error) // get slice of requests from bin by position | |
CreateBin(bin *Bin) error // create bin in memory storage | |
UpdateBin(bin *Bin) error // save | |
CreateRequest(bin *Bin, req *Request) error | |
} | |
</source> | |
<blockquote>Интерфейсы в Go являются контрактом, связывающим ожидаемую функциональность и актуальную реализацию. В нашем случае, мы описали интерфейс storage, который будем использовать в дальнейшем в программе, но в зависимости от настроек, имплементация может быть совершенно разной (например это может быть Redis или Mongo). Подробнее об <a href="http://golangtutorials.blogspot.com/2011/06/interfaces-in-go.html ">интерфейсах</a>.</blockquote> | |
Помимо этого создадим базовый объект storage, в котором будут вспомогательные поля, которые потребуются нам в каждой имплементации: | |
<source code="go"> | |
type BaseStorage struct { | |
maxRequests int | |
} | |
</source> | |
Теперь пришло время реализовать поведение нашего интерфейса хранилища. Для начала попробуем всё хранить в памяти, разграничивая параллельный доступ к данным <a href="http://ru.wikipedia.org/wiki/Мьютекс">мьютексами</a>. | |
Создадим файл memory.go В основе нашего хранилища будет простая структура данных: | |
<source code="go"> | |
type MemoryStorage struct { | |
BaseStorage | |
sync.RWMutex | |
binRecords map[string]*BinRecord | |
} | |
</source> | |
Она состоит из вложенных, анонимных полей BaseStorage и sync.RWMutex. <blockquote>Анонимные поля дают нам возможность вызывать методы и поля анонимных структур напрямую. Например, если у нас есть переменная obj типа MemoryStorage, мы можем доступиться к полю maxRequests напрямую obj.BaseStorage.maxRequests, либо как будто они члены самого MemoryStorage obj.maxRequests. Подробнее об анонимных полях в структурах данных можно почитать в <a href="http://golangtutorials.blogspot.com/2011/06/anonymous-fields-in-structs-like-object.html">документации</a>.</blockquote> | |
<a href="http://golang.org/pkg/sync/#RWMutex">RWMutex</a> нам нужен, чтобы блокировать одновременную работу со словарём binRecords, так как Go не гарантирует правильного поведения при параллельном изменении данных в словарях. | |
Сами данные будут хранится в поле binRecords, которой является словарём с ключами из поля name Bin объектов и данными вида BinRecord. | |
<source code="go"> | |
type BinRecord struct { | |
bin *Bin | |
requests []*Request | |
requestMap map[string]*Request | |
} | |
</source> | |
В этой структуре собраны все нужные данные. Ссылки на запросы хранятся в двух полях, в списке, где они идут по порядку добавления и в словаре, для более быстрого поиска по идентификатору. | |
<blockquote>Словари в Go в текущей реализации - это хеш таблицы, поэтому поиск элемента в словаре имеет константное значение. Подробнее о внутреннем устройстве можно ознакомиться в этой <a href="http://www.goinggo.net/2013/12/macro-view-of-map-internals-in-go.html">прекрасной статье</a>.</blockquote> | |
Так же для объекта BinRecord реализован метод для обрезания лишних запросов, который просто удаляет ненужные элементы из requests и requestMap. | |
<source code="go"> | |
func (binRecord *BinRecord) ShrinkRequests(size int) { | |
if size > 0 && len(binRecord.requests) > size { | |
requests := binRecord.requests | |
lenDiff := len(requests) - size | |
removed := requests[:lenDiff] | |
for _, removedReq := range removed { | |
delete(binRecord.requestMap, removedReq.Id) | |
} | |
requests = requests[lenDiff:] | |
binRecord.requests = requests | |
} | |
} | |
</source> | |
Все методы MemoryStorage имплементируют поведение интерфейса Storage, так же у нас есть вспомогательный метод getBinRecord, в котором мы можем прочитать нужную нам запись. В момент когда мы читаем запись, мы ставим блокировку на чтение и сразу же указываем отложенный вызов снятия блокировки в defer. Выражение defer позволяет нам указывать функцию, которая будет всегда выполнена по завершении работы функции, даже если функцию была прервана паникой. Подробнее почитать о defer можно в <a href="http://blog.golang.org/defer-panic-and-recover">документации</a> | |
Подробнее рассматривать каждый метод MemoryStorage смысла нет, там всё и так не сложно, вы можете заглянуть в код самостоятельно. | |
<spoiler title="Код MemoryStorage"> | |
<source code="go"> | |
package skimmer | |
import ( | |
"errors" | |
"sync" | |
) | |
type MemoryStorage struct { | |
BaseStorage | |
sync.RWMutex | |
binRecords map[string]*BinRecord | |
} | |
type BinRecord struct { | |
bin *Bin | |
requests []*Request | |
requestMap map[string]*Request | |
} | |
func (binRecord *BinRecord) ShrinkRequests(size int) { | |
if size > 0 && len(binRecord.requests) > size { | |
requests := binRecord.requests | |
lenDiff := len(requests) - size | |
removed := requests[:lenDiff] | |
for _, removedReq := range removed { | |
delete(binRecord.requestMap, removedReq.Id) | |
} | |
requests = requests[lenDiff:] | |
binRecord.requests = requests | |
} | |
} | |
func NewMemoryStorage(maxRequests int) *MemoryStorage { | |
return &MemoryStorage{ | |
BaseStorage{ | |
maxRequests: maxRequests, | |
}, | |
sync.RWMutex{}, | |
map[string]*BinRecord{}, | |
} | |
} | |
func (storage *MemoryStorage) getBinRecord(name string) (*BinRecord, error) { | |
storage.RLock() | |
defer storage.RUnlock() | |
if binRecord, ok := storage.binRecords[name]; ok { | |
return binRecord, nil | |
} | |
return nil, errors.New("Bin not found") | |
} | |
func (storage *MemoryStorage) LookupBin(name string) (*Bin, error) { | |
if binRecord, err := storage.getBinRecord(name); err == nil { | |
return binRecord.bin, nil | |
} else { | |
return nil, err | |
} | |
} | |
func (storage *MemoryStorage) LookupBins(names []string) ([]*Bin, error) { | |
bins := []*Bin{} | |
for _, name := range names { | |
if binRecord, err := storage.getBinRecord(name); err == nil { | |
bins = append(bins, binRecord.bin) | |
} | |
} | |
return bins, nil | |
} | |
func (storage *MemoryStorage) CreateBin(bin *Bin) error { | |
storage.Lock() | |
defer storage.Unlock() | |
binRec := BinRecord{bin, []*Request{}, map[string]*Request{}} | |
storage.binRecords[bin.Name] = &binRec | |
return nil | |
} | |
func (storage *MemoryStorage) UpdateBin(_ *Bin) error { | |
return nil | |
} | |
func (storage *MemoryStorage) LookupRequest(binName, id string) (*Request, error) { | |
if binRecord, err := storage.getBinRecord(binName); err == nil { | |
if request, ok := binRecord.requestMap[id]; ok { | |
return request, nil | |
} else { | |
return nil, errors.New("Request not found") | |
} | |
} else { | |
return nil, err | |
} | |
} | |
func (storage *MemoryStorage) LookupRequests(binName string, from int, to int) ([]*Request, error) { | |
if binRecord, err := storage.getBinRecord(binName); err == nil { | |
requestLen := len(binRecord.requests) | |
if to >= requestLen { | |
to = requestLen | |
} | |
if to < 0 { | |
to = 0 | |
} | |
if from < 0 { | |
from = 0 | |
} | |
if from > to { | |
from = to | |
} | |
reversedLen := to - from | |
reversed := make([]*Request, reversedLen) | |
for i, request := range binRecord.requests[from:to] { | |
reversed[reversedLen-i-1] = request | |
} | |
return reversed, nil | |
} else { | |
return nil, err | |
} | |
} | |
func (storage *MemoryStorage) CreateRequest(bin *Bin, req *Request) error { | |
if binRecord, err := storage.getBinRecord(bin.Name); err == nil { | |
storage.Lock() | |
defer storage.Unlock() | |
binRecord.requests = append(binRecord.requests, req) | |
binRecord.requestMap[req.Id] = req | |
binRecord.ShrinkRequests(storage.maxRequests) | |
binRecord.bin.RequestCount = len(binRecord.requests) | |
return nil | |
} else { | |
return err | |
} | |
} | |
</source> | |
</spoiler> | |
Теперь, когда у нас есть хранилище, можно приступать к описанию api. Посмотрим что у нас изменяется. | |
Во первых мы добавляем поддержку нашего нового хранилища. | |
<source code="go"> | |
memoryStorage := NewMemoryStorage(MAX_REQUEST_COUNT) | |
api.MapTo(memoryStorage, (*Storage)(nil)) | |
</source> | |
Теперь в любом хендлере мы можем добавить параметр типа Storage и получить доступ к нашему хранилищу. Что мы и делаем, заменив во всех обработчиках запросов к Bin работу со словарём на вызовы к Storage. | |
<source code="go"> | |
api.Post("/api/v1/bins/", func(r render.Render, storage Storage){ | |
bin := NewBin() | |
if err := storage.CreateBin(bin); err == nil { | |
history = append(history, bin.Name) | |
r.JSON(http.StatusCreated, bin) | |
} else { | |
r.JSON(http.StatusInternalServerError, ErrorMsg{err.Error()}) | |
} | |
}) | |
api.Get("/api/v1/bins/", func(r render.Render, storage Storage){ | |
if bins, err := storage.LookupBins(history); err == nil { | |
r.JSON(http.StatusOK, bins) | |
} else { | |
r.JSON(http.StatusInternalServerError, ErrorMsg{err.Error()}) | |
} | |
}) | |
api.Get("/api/v1/bins/:bin", func(r render.Render, params martini.Params, storage Storage){ | |
if bin, err := storage.LookupBin(params["bin"]); err == nil{ | |
r.JSON(http.StatusOK, bin) | |
} else { | |
r.JSON(http.StatusNotFound, ErrorMsg{err.Error()}) | |
} | |
}) | |
</source> | |
Во вторых, добавили обработчики для объектов типа Request. | |
<source code="go"> | |
// список всех реквестов | |
api.Get("/api/v1/bins/:bin/requests/", func(r render.Render, storage Storage, params martini.Params, | |
req *http.Request){ | |
if bin, error := storage.LookupBin(params["bin"]); error == nil { | |
from := 0 | |
to := 20 | |
if fromVal, err := strconv.Atoi(req.FormValue("from")); err == nil { | |
from = fromVal | |
} | |
if toVal, err := strconv.Atoi(req.FormValue("to")); err == nil { | |
to = toVal | |
} | |
if requests, err := storage.LookupRequests(bin.Name, from, to); err == nil { | |
r.JSON(http.StatusOK, requests) | |
} else { | |
r.JSON(http.StatusInternalServerError, ErrorMsg{err.Error()}) | |
} | |
} else { | |
r.Error(http.StatusNotFound) | |
} | |
}) | |
// доступ к конкретному экземпляру Request | |
api.Get("/api/v1/bins/:bin/requests/:request", func(r render.Render, storage Storage, params martini.Params){ | |
if request, err := storage.LookupRequest(params["bin"], params["request"]); err == nil { | |
r.JSON(http.StatusOK, request) | |
} else { | |
r.JSON(http.StatusNotFound, ErrorMsg{err.Error()}) | |
} | |
}) | |
// сохранение http запроса в объект Request контейнера Bin(name) | |
api.Any("/bins/:name", func(r render.Render, storage Storage, params martini.Params, | |
req *http.Request){ | |
if bin, error := storage.LookupBin(params["name"]); error == nil { | |
request := NewRequest(req, REQUEST_BODY_SIZE) | |
if err := storage.CreateRequest(bin, request); err == nil { | |
r.JSON(http.StatusOK, request) | |
} else { | |
r.JSON(http.StatusInternalServerError, ErrorMsg{err.Error()}) | |
} | |
} else { | |
r.Error(http.StatusNotFound) | |
} | |
}) | |
</source> | |
Попробуем запустить то, что у нас получилось и отправить несколько запросов. | |
Создадим контейнер Bin для наших HTTP запросов | |
<source code="bash"> | |
> curl -i -X POST "127.0.0.1:3000/api/v1/bins/" | |
HTTP/1.1 201 Created | |
Content-Type: application/json; charset=UTF-8 | |
Date: Mon, 03 Mar 2014 12:19:28 GMT | |
Content-Length: 76 | |
{"name":"ws87ui","created":1393849168,"updated":1393849168,"requestCount":0} | |
</source> | |
Отправим запрос в наш контейнер | |
<source code="bash"> | |
> curl -X POST -d "fizz=buzz" http://127.0.0.1:3000/bins/ws87ui | |
{"id":"i0aigrrc1b40","created":1393849284,...} | |
</source> | |
Проверим, сохранился ли наш запрос: | |
<source code="bash"> | |
> curl http://127.0.0.1:3000/api/v1/bins/ws87ui/requests/ | |
[{"id":"i0aigrrc1b40","created":1393849284,...}] | |
</source> | |
Кажется, всё работает как надо, но чтобы быть в этом точно уверенными нужно покрыть код тестами. | |
Продолжение статьи во <a href="http://habrahabr.ru/post/214425/">второй части</a>, где мы узнаем как писать тесты, реализуем одностраничный веб-интерфейс на основе AngularJS и Bootstrap, добавим немного приватности и внедрим поддержку Redis для хранения. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment