Pull to refresh

Изучение Go путём портирования небольшого Python веб-бекенда

Reading time 9 min
Views 15K
Original author: Ben Hoyt
Содержание: Чтобы выучить Go, я портировал свой бекенд небольшого сайта с Python на Go и получил забавный и безболезненный опыт в процессе.

Я хотел начать учить Go какое-то время — мне нравилась его философия: маленький язык, приятная кривая обучения и очень быстрая компиляция (как для статически-типизированного языка). Что меня наконец заставило шагнуть дальше и таки начать его учить, так это то, что я стал видеть всё больше и больше быстрых и серьезных программ, написанных на Go — Docker и ngrok, в частности, из тех, которые я недавно использовал.


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


Портирование


Я начал с парочки 20-ти строчных скриптов на Go, но этого было как-бы мало, чтобы понять язык и экосистему. Поэтому я решил взять проект побольше и выбрал для портирования бекенд для моего сайта GiftyWeddings.com.


На Питоне это было около 1300 строк кода, используя Flask, WTForms, Peewee, SQLite и ещё несколько библиотек для S3, ресайзинга картинок и т.д.


Для Go-версии я хотел использовать как можно меньше внешних зависимостей, чтобы лучше освоить язык и как можно больше поработать со стандартной библиотекой. В частности, у Go есть отличные библиотеки для работы с HTTP, и я решил пока не смотреть на веб-фреймворки вообще. Но я всё же использовал несколько сторонних библиотек для S3, Stripe, SQLite, работы с паролями и ресайза картинок.


Из-за статической типизации Go и так как я использовал меньше библиотек, я ожидал, что код будет более, чем вдвое больше оригинального количества. Но в результате получилось 1900 строк (примерно на 50% больше, чем 1300 на Python).


Сам процесс портирования проходил очень гладко, и большая часть бизнес-логики портировалась практически механическим переводом кода строка в строку из Python в Go. Я был удивлён как хорошо многие концепции из Python транслируются в Go, вплоть до things[:20] синтаксиса слайсов.


Я также портировал часть библиотеки itsdangerous, которую использует Flask, так что я мог прозрачно декодировать сессионные cookies из Python-сервиса во время миграции на Go-версию. Весь код для криптографии, сжатия и сериализации был в стандартной библиотеке и это был простой процесс.


В целом, между Go Tour, Effective Go и поглядывание на различные примеры кода в интернете, процесс изучения языка шёл без усилий. Документация достаточно краткая, но очень хорошо написана.


Также очень порадовал инструментарий языка: всё что нужно для сборки, запуска, форматирования и тестирования кода доступно через под-команды go. Во время разработки, я просто использовал go run *.go, чтобы скомпилировать на лету и запустить сервер. Компиляция и запуск занимала примерно секунду, что было глотком свежего воздухе после битвы на мечах от 20 секунд инкрементальной и 20 минут полной компиляции в Scala.


Тестирование


В стандартной библиотеке Go есть базовый пакет testing и готовый запускатель тестов (go test), который находит, компилирует и запускает ваши тесты. Этот пакет очень лёгкий и простой (возможно даже чересчур), но вы можете элементарно добавить свои хелперы, если необходимо.


В дополнение к unit-тестам, я написал тестовый скрипт (также с использование пакета testing), который запускает тесты HTTP натравленные на реальный сервер Gifty Weddings. Я сделал это на уровне HTTP, а не на уровне Go кода нарочно, чтобы иметь возможность натравить этот тест на старый сервер на Python и убедиться, что результаты идентичные. Это дало мне достаточную уверенность, что всё работает как нужно перед тем, как я подменил сервера.


Я также сделал немного white-box тестирования: скрипт проверяет ответы, но он также декодирует cookies и проверяет, что они содержат корректные данные.


Вот один из примеров такого теста, который создает registry и удаляет подарок:


func TestDeleteGift(t *testing.T) {
    client := NewClient(baseURL)
    response := client.PostJSONOK(t, "/api/create", nil)
    AssertMatches(t, response["slug"], `temp\d+`)

    slug := response["slug"].(string)
    html := client.GetOK(t, "/"+slug, "text/html")
    _, gifts := ParseRegistryAndGifts(t, html)
    AssertEqual(t, len(gifts), 3)

    gift := gifts[0].(map[string]interface{})
    giftID := int(gift["id"].(float64))
    response = client.PostJSONOK(t, fmt.Sprintf("/api/registries/%s/gifts/%d/delete", slug, giftID), nil)
    expected := map[string]interface{}{
        "id": nil,
    }
    AssertDeepEqual(t, response, expected)

    html = client.GetOK(t, "/"+slug, "text/html")
    _, gifts = ParseRegistryAndGifts(t, html)
    AssertEqual(t, len(gifts), 2)
}

Кросс-компиляция


Я считаю, что это просто нереально круто — вы можете на macOS сказать:


$ GOOS=linux GOARCH=amd64 go build

и это скомпилирует вам готовый к использованию бинарник под Linux прямо на вашем Mac. И, конечно, вы можете делать это в обратном направлении, и кросс-компилировать в и из Windows тоже. Оно просто работает.


Кросс-компиляция cgo-модулей (вроде SQLite) была немного сложнее, так как требовала установки корректной версии GCC для компиляции — что, в отличие от Go, не слишком тривиально. В результате я просто использовал Docker со следующей командой для сборки под Linux:


$ docker run --rm -it -v ~/go:/go -w /go/src/gifty golang:1.9.1 \
    go build -o gifty_linux -v *.go

Хорошие моменты


Одна из самых классных вещей в Go это то что, всё чувствуется как железобетонно надежное: стандартная библиотека, инструментарий (go под-команды) и даже сторонние библиотеки. Моё внутреннее чутье показывает, что это частично из-за того, что в Go нет исключений, и есть некая навязанная "культура обработки ошибок" из-за способа обработки ошибок.


Сетевые и HTTP библиотеки особенно выглядят круто. Вы можете запустить net/http веб сервер (production-уровня и с поддержкой HTTP/2, учтите) буквально в пару строк кода.


Стандартная библиотека содержит большинство из необходимых вещей: html/template, ioutil.WriteFile, ioutil.TempFile, crypto/sha1, encoding/base64, smtp.SendMail, zlib, image/jpeg и image/png и можно продолжать и дальше. API библиотек очень понятны, и там где есть низкоуровневые API, они обычно завёрнуты в более высокоуровневые функции, для наиболее частых способов использования.


В итоге, написать веб бекенд без фреймворка на Go оказалось совсем несложно.


Я был приятно удивлён, как легко было работать с JSON в статически-типизированном языке: вы просто вызываете json.Unmarshal прямо в структуру, и с помощью рефлексии (reflection) правильные поля заполняются автоматически. Подгрузить конфигурацию сервера из json-файла было очень просто:


err = json.Unmarshal(data, &config)
if err != nil {
    log.Fatalf("error parsing config JSON: %v", err)
}

Кстати, об err != nil — это не было так ужасно, как некоторые люди придумывают (проверка встречается примерно 70 раз на мои 1900 строк кода). И это даёт очень хорошее чувство "это реально надёжно, я корректно обрабатываю каждую ошибку".


Кстати, так как каждый обработчик запроса работает в своей горутине, я также использовал вызовы panic() для таких вещей как вызовы к базе данных, которые "должны всегда работать". И на самом верхнем уровне, я ловил эти паники с помощью recover() и корректно логировал их и даже добавил немного кода, чтобы отправлять стектрейс мне на почту.


После Python и Flask, очень чесались руки использовать специальное значение для паники для Not Found или Bad Request ответов, но я сдержал эти позывы и решил идти более идиоматическим путём Go (правильные возвратные значения).


Также мне очень нравится единое синхронное API для всего, плюс великолепное ключевое слово go чтобы запускать вещи в фоновой горутине. Это сильно контрастирует с Python/C#/JavaScript-овыми асинхронными API — которые приводят к новым API для каждой функции ввода-вывода, что увеличивает поверхность API вдвое.


Формат в time.Parse() немного причудливый с идеей "референсной даты", но на практике это очень просто читать когда вы возвращаетесь к коду позже (нету вот этого "ещё раз, что же %b тут означает?")


Библиотека context отняла немного времени, чтобы въехать в неё, но она оказалась полезной для передачи различной дополнительной информации (данные сессии юзера и т.д.) всем вовлеченным в обработку запроса.


Общие причуды


У Go определенно их меньше, чем у Python (но, опять же, Go не 26 лет, как-никак), но всё же есть несколько. Вот некоторые, которые я заметил в процессе моего портирования.


Нельзя взять адрес результата функции или выражения. Вы можете взять адрес переменной или, как особый случай, литерала структуры, вроде &Point{2, 3}, но вы не можете сделать &time.Now(). Это немного раздражало, потому что заставляло создавать временную переменную:


now := time.Now()
thing.TimePtr = &now

Мне кажется, Go компилятор мог спокойно создавать её за меня и позволить писать thing.TimePtr = &time.Now().


Хендлер HTTP принимает http.ResponseWriter вместо возвращения ответа. API http.ResponseWriter немного странное для основных случаев, и вы должны запомнить правильный порядок вызова Header().Set, WriteHeader и Write. Было бы проще, если хендлеры просто возвращали некий объект с ответом.


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


Вероятно была веская причина такого дизайна (эффективность? Сочетаемость?), но я не могу с ходу так её увидеть. Я мог бы легко сделать обёртки для моих хендлеров, чтобы возвращали объект, но я решил так не делать.


Шаблонизатор вроде ничего, но тоже есть причуды Пакет html/template мне показался довольно хорошим, но у меня заняло время понять что такое "ассоциированные шаблоны" и для чего они вообще. Чуть больше примеров, в частности для наследования шаблонов, очень бы были кстати. Мне понравилось, что шаблонизатор достаточно хорошо расширяется (например, легко добавить свои функции).


Загрузка шаблонов немного странная, поэтому я обернул html/template в свой пакет для рендеринга, который загружал сразу всю директорию и базовый шаблон.


Синтаксис шаблонов тоже ок, но синтаксис выражений слегка странный. Мне кажется, было бы лучше использовать синтаксис более похожий на сам Go. По факту, я в следующий раз, скорее всего, буду использовать что-то вроде ego или quicktemplate, потому что они, по сути, используют синтаксис Go и не принуждают учить ещё один синтаксис для выражений.


Пакет database/sql слишком легкий. Я не самый большой фанат ORM, но было бы неплохо, если бы database/sql мог использовать рефлексию и заполнять поля структур по аналогии с encoding/json. Scan() ну прямо совсем низкоуровневый. Хотя, есть пакет sqlx, который, похоже, делает именно это.


Тестирование слишком простое Хоть я и фанат go test и простоты тестирования в Go в целом, но, мне кажется, было бы хорошо иметь в штатном наборе хотя бы функции в стиле AssertEqual. В итоге, я просто написал свои AssertEqual и AssertMatches функции. Хотя, опять же, похоже, что есть сторонние пакеты, которые именно это и делают: stretchr/testify.


Пакет flag причудливый. По всей видимости, дизайн был основан на пакете для флагов командной строки в Google, но формат -одинДефис всё таки смотрится странно, учитывая то, что GNU-формат с -s и --long это практически стандарт. Опять же, есть масса замен этому пакету, включая drop-in замены, в которых даже код не нужно менять.


Встроенный роутер URL (ServeMux) слишком простенький, поскольку позволяет делать проверки только по фиксированным префиксам, но создание роутера, основанного на regexp было тривиальной задачей (дюжина строк кода).


Претензии от питониста


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


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


gifts := []map[string]interface{}{}
for _, g := range registryGifts {
    gifts = append(gifts, g.Map())
}

в это:


gifts := [g.Map() for _, g := range registryGifts]

Хотя, на самом деле, таких мест было гораздо меньше, чем я ожидал.


Аналогично, sort.Interface чересчур многословен. Добавление sort.Slice() было правильным шагом. Но мне всё же нравится как легко сортировать по ключу в Python, без касания индексов слайсов вообще. Например, чтобы отсортировать список строк без учета регистра, в Python это будет:


strs.sort(key=str.lower)

а в Go:


sort.Slice(strs, func(i, j int) bool {
    return strings.ToLower(strs[i]) < strings.ToLower(strs[j])
})

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


Почему Go?


Я не собираюсь перестать использовать Python в обозримом будущем. Я продолжаю использовать его для своих скриптов, маленьких проектов и веб-бекендов. Но я всерьез смотрю на Go для проектов побольше (статическая типизация делает рефакторинг гораздо более простым) и для утилит или систем, где важна производительность языка (хотя, если честно, со всеми низкоуровневыми библиотеками в Python, которые написаны на C, она часто не так важна).


Резюмируя, некоторые из причин, по которым мне понравился Go, и по которым может понравится и вам:


  • хорошая кривая обучаемости: это маленький язык с вполне читабельной спецификацией и простой системой типов
  • быстрое время компиляции
  • некоторые уникальные и отличные фичи: слайсы, горутины и ключевое слово go, defer, := для лаконичного вывода типа, система типов на интерфейсах.
  • отличная и краткая документация для языка и стандартной библиотеки
  • отличный встроенный инструментарий:
    • go build: собрать программу (не нужен никакой Makefile)
    • go fmt: автоматически отформатировать код, больше нет войн о стиле
    • go test: запустить тесты во всех *_test.go файлах
    • go run: скомпилировать и запустить программу мгновенно, по ощущениям как скриптинг
    • dep: менеджер пакетов, скоро будет go dep
    • Хорошая и растущая экосистема (библиотеки для баз данных, веб фреймворки, AWS, Stripe, GraphQL и т.д.)
    • Стабильность: Go 1 уже существует более 5 лет и имеет строгое обещание совместимости. Авторы Go очень консервативны в плане того, что они добавляют в язык, и на это есть веские причины.
    • Философия языка не всем понравится, но она очень хорошо ложится на мою ментальную модель: простота, ясность и скорость.

Так что вперёд, попробуйте написать что-нибудь на Go (Write in Go)!

Tags:
Hubs:
+21
Comments 36
Comments Comments 36

Articles