Вредные советы для Go-программиста


image

После десятилетий программирования на Java, последние несколько лет я в основном работал на Go. Работать с Go — здорово, прежде всего потому, что за кодом очень легко следовать. Java упростила модель программирования C ++, удалив множественное наследование, ручное управление памятью и перегрузку операторов. Go делает то же самое, продолжая двигаться к простому и понятному стилю программирования, полностью удаляя наследование и перегрузку функций. Простой код — читаемый код, а читаемый код — поддерживаемый код. И это здорово для компании и моих сотрудников.

Как и во всех культурах, у разработки программного обеспечения есть свои легенды, истории, которые пересказываются у кулера для воды. Все мы слышали о разработчиках, которые вместо того, чтобы сосредоточиться на создании качественного продукта, зацикливаются на защите собственной работы от посторонних. Им не нужен поддерживаемый код, потому что это означает, что другие люди смогут его понять и доработать. А возможно ли такое на Go? Можно ли сделать код на Go настолько сложным? Скажу сразу – дело это непростое. Давайте рассмотрим возможные варианты.

Вы думаете: «Насколько сильно можно испоганить код на языке программирования? Возможно ли написать настолько ужасный код на Go, чтобы его автор стал незаменимым в компании?» Не волнуйтесь. Когда я был студентом, у меня был проект, в котором я поддерживал чужой код на Lisp-e, написанный аспирантом. По сути, он умудрился написать код на Fortran-e, используя Lisp. Код выглядел примерно так:

(defun add-mult-pi (in1 in2)
    (setq a in1)
    (setq b in2)
    (setq c (+ a b))
    (setq d (* 3.1415 c)
    d
)

Там были десятки файлов такого кода. Он был абсолютно ужасным и абсолютно гениальным одновременно. Я потратил месяцы, пытаясь разобраться в нем. По сравнению с этим написать плохой код на Go – раз плюнуть.

Есть много разных способов сделать код неподдерживаемым, но мы рассмотрим лишь несколько. Чтобы правильно сделать зло, нужно сперва научиться делать добро. Поэтому мы, сначала посмотрим, как пишут «добрые» программисты Go, а затем разберем, как можно сделать обратное.

Плохая упаковка


Пакеты — удобная тема, чтобы с нее начать. Каким образом организация кода может ухудшить читаемость?

В Go имя пакета используется для ссылки на экспортируемую сущность (например, `fmt.Println` или `http.RegisterFunc`). Поскольку нам видно имя пакета, «добрые» программисты Go следят за тем, чтобы это имя описывало то, что представляют собой экспортируемые сущности. У нас не должно быть пакетов util, потому что нам не подойдут имена вроде `util.JSONMarshal`, — нам нужен `json.Marshal`.

«Добрые» разработчики Go также не создают отдельного пакета для DAO или модели. Для тех, кто не знаком с этим термином, DAO — это «data access object (объект доступа к данным)» — слой кода, который взаимодействует с вашей базой данных. Раньше я работал в компании, где 6 Java-сервисов импортировали одну и ту же библиотеку DAO для доступа к одной и той же базе данных, которую они использовали совместно, потому что «…ну, знаете, — микросервисы же…».

Если у вас есть отдельный пакет со всеми вашими DAO, то возрастает вероятность того, что вы получите циклическую зависимость между пакетами, которая в Go запрещена. И если у вас есть несколько сервисов, которые подключают данный пакет DAO в качестве библиотеки, вы также можете столкнуться с ситуацией, когда изменение в одной службе требует обновления всех ваших сервисов, иначе что-то сломается. Это называется распределенным монолитом, и его невероятно сложно обновлять.

Когда вы знаете, как должна работать упаковка, и что ее ухудшает, «начать служить злу» становится просто. Плохо организуйте свой код и дайте своим пакетам дурные имена. Разбейте ваш код на пакеты, такие как model, util и dao. Если вы действительно хотите начать «творить беспредел», попробуйте создавать пакеты в честь вашего котика или любимого цвета. Когда люди сталкиваются с циклическими зависимостями или распределенными монолитами из-за того, что пытаются использовать ваш код, вам приходится вздыхать, закатывать глаза и говорить им, что они просто делают не так…

Неподходящие интерфейсы


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

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

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

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

Это облегчает понимание вашего кода, потому что объявление метода не только определяет, какие данные ему нужны, но и точно указывает, какую функциональность он собирается использовать. Это одна из причин, почему хорошие разработчики Go следуют совету: «Принимайте интерфейсы, возвращайте структуры».

Но только потому, что это хорошая практика, вовсе не значит, что вы должны так делать…
Лучший способ сделать ваши интерфейсы «злыми» — вернуться к принципам использования интерфейсов из других языков, т.е. заранее определять интерфейсы, как часть вызываемого кода. Определяйте огромные интерфейсы со множеством методов, которые используются всеми клиентами службы. Становится неясно, какие методы действительно необходимы. Это усложняет код, а усложнение, как известно, — лучший друг «злого» программиста.

Передача указателей «до кучи»


Прежде чем объяснить, что это значит, надо немного пофилософствовать. Если отвлечься и подумать, — каждая написанная программа делает одно и то же. Она принимает данные, обрабатывает их, а затем отправляет обработанные данные в другое место. Это так, независимо от того, пишете ли вы систему начисления заработной платы, принимаете HTTP-запросы и возвращаете обратно веб-страницы, или даже чекаете джойстик, чтобы отследить нажатие кнопки — программы обрабатывают данные.

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

В Go у нас есть ссылочные типы и типы значений. Разница между ними в том, ссылается ли переменная на копию данных или на место данных в памяти. Указатели, слайсы, мапы, каналы, интерфейсы и функции являются ссылочными типами, а все остальное — типом значения. Если вы присваиваете переменную типа значения другой переменной, она создает копию значения; изменение одной переменной не меняет значение другой.

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

func main() {
    //тип значений
    a := 1
    b := a
    b = 2
    fmt.Println(a, b) // prints 1 2
    //ссылочные типы
    c := &a
    *c = 3
    fmt.Println(a, b, *c) // prints 3 2 3
}

«Добрые» разработчики Go хотят упростить понимание того, как собираются данные. Они стараются использовать тип значений в качестве параметров функций как можно чаще. В Go нет способа пометить поля в структурах или параметры функции как final. Если функция использует параметры-значения, изменение параметров не изменит переменных в вызывающей функции. Все, что может сделать вызываемая функция — это вернуть значение в вызывающую функцию. Таким образом, если вы заполняете структуру, вызывая функцию с параметрами-значений, вы можете не бояться передавать данные в структуру, поскольку понимаете, откуда пришло каждое поле в структуре.

type Foo struct {
    A int
    B string
}
func getA() int {
    return 20
}
func getB(i int) string {
    return fmt.Sprintf("%d",i*2)
}
func main() {
    f := Foo{}
    f.A = getA()
    f.B = getB(f.A)
    //Я точно знаю, что пришло в f
    fmt.Println(f)
}

Ну и как нам стать «злыми»? Очень просто – перевернув эту модель.

Вместо того чтобы вызывать функции, которые возвращают нужные значения, вы передаете указатель на структуру в функции и позволяете им вносить изменения в структуру. Поскольку каждая функция владеет всей структурой, единственный способ узнать, какие поля изменяются, — просмотреть весь код. У вас также могут быть неявные зависимости между функциями – 1я функция передает данные, необходимые 2й функции. Но в самом коде ничто не указывает на то, что вы должны сначала вызвать 1ю функцию. Если вы построите свои структуры данных таким образом, можете быть уверены, — никто не поймет, что делает ваш код.

type Foo struct {
    A int
    B string
}
func setA(f *Foo) {
    f.A = 20
}
//Секретная зависимость для f.A!
func setB(f *Foo) {
    f.B = fmt.Sprintf("%d", f.A*2)
}
func main() {
    f := Foo{}
    setA(&f)
    setB(&f)
    //Кто знает, что setA и setB
    //делают и от чего зависят?
    fmt.Println(f)
}

Всплытие паники


Теперь мы приступаем к обработке ошибок. Наверное, вы думаете, что плохо писать программы, которые обрабатывают ошибки, примерно, на 75%, — и я не скажу, что вы неправы. Код Go часто заполнен обработкой ошибок с головы до пят. И конечно, было бы удобно обрабатывать их не так прямолинейно. Ошибки случаются, и их обработка — это то, что отличает профессионалов от новичков. Невнятная обработка ошибок приводит к нестабильным программам, которые трудно отлаживать и сложно поддерживать. Иногда быть «добрым» программистом — значит «напрягаться».

func (dus DBUserService) Load(id int) (User, error) {
    rows, err := dus.DB.Query("SELECT name FROM USERS WHERE ID = ?", id)
    if err != nil {
        return User{}, err
    }
    if !rows.Next() {
        return User{}, fmt.Errorf("no user for id %d", id)
    }
    var name string
    err = rows.Scan(&name)
    if err != nil {
        return User{}, err
    }
    err = rows.Close()
    if err != nil {
        return User{}, err
    }
    return User{Id: id, Name: name}, nil
}

Многие языки, такие как C ++, Python, Ruby и Java, используют исключения для обработки ошибок. Если что-то идет не так, разработчики на этих языках бросают или вызывают исключение, ожидая, что какой-нибудь код его обработает. Конечно, программа рассчитывает, что клиент знает о возможном выбросе ошибке в данном месте, чтобы была возможно сгенерировать исключение. Потому что, за исключением (без каламбура) проверяемых исключений Java, в языках или функциях нет ничего в сигнатуре метода, чтобы указать, что может возникнуть исключение. Так как же разработчикам узнать, о каких исключениях надо беспокоиться? У них есть два варианта:

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

Итак, как же нам принести это зло в Go? Злоупотребляя паникой (panic) и рекавером (recover), конечно! Паника предназначена для таких ситуаций, как «диск отвалился» или «сетевая карта взорвалась». Но не для таких — «кто-то передал string вместо int».

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

func PanicIfErr(err error) {
    if err != nil {
        panic(err)
    }
}

Вы можете использовать PanicIfErr, чтобы обертывать чужие ошибки, ужимать код. Не надо больше уродской обработки ошибок! Любая ошибка — теперь паника. Это так продуктивно!

func (dus DBUserService) LoadEvil(id int) User {
    rows, err := dus.DB.Query(
                 "SELECT name FROM USERS WHERE ID = ?", id)
    PanicIfErr(err)
    if !rows.Next() {
        panic(fmt.Sprintf("no user for id %d", id))
    }
    var name string
    PanicIfErr(rows.Scan(&name))
    PanicIfErr(rows.Close())
    return User{Id: id, Name: name}
}

Вы можете помещать рекавер где-то поближе к началу программы, может, в собственном мидлвер (middleware). А потом говорить, что вы не только обрабатываете ошибки, но и делаете чужой код чище. Совершать зло тем, что делаешь добро, — это лучший вид зла.

func PanicMiddleware(h http.Handler) http.Handler {
    return http.HandlerFunc(
        func(rw http.ResponseWriter, req *http.Request){
            defer func() {
                if r := recover(); r != nil {
                   fmt.Println("Да, что-то произошло.")
                }
            }()
            h.ServeHTTP(rw, req)
        }
    )
}

Настройка побочных эффектов


Далее мы создадим сайд-эффект. Помните, «добрый» разработчик Go хочет понять, как данные проходят через программу. Лучший способ знать, через что проходят данные — это настройка явных зависимостей в приложении. Даже сущности, которые соответствуют одному и тому же интерфейсу, могут сильно разниться в поведениях. Например, код, который сохраняет данные в памяти, и код, который обращается к БД за той же работой. Тем не менее, существуют способы установить зависимости в Go без явных вызовов.

Как и во многих других языках, в Go есть способ магически выполнять код, не вызывая его напрямую. Если вы создадите функцию с именем init без параметров, она автоматически запустится при загрузке пакета. И, чтобы еще более запутать, если в одном файле несколько функций с именем init или несколько файлов в одном пакете, они запустятся все.

package account
type Account struct{
    Id int
    UserId int
}
func init() {
    fmt.Println("Я исполняюсь магически!")
}
func init() {
    fmt.Println("Я тоже исполняюсь магически, и меня тоже зовут init()")
}

Функции init часто связаны с пустым импортом. В Go есть специальный способ объявления импорта, который выглядит как `import _“ github.com/lib/pq`. Когда вы устанавливаете пустой идентификатор имени для импортированного пакета, в нем запускается метод init, но при этом не показывает ни один из идентификаторов пакета. Для некоторых библиотек Go — таких как драйверы БД или форматы изображений — вы должны их загрузить, включив пустой импорт пакета, просто чтобы вызвать функцию init, чтобы пакет мог зарегистрировать свой код.

package main

import _ "github.com/lib/pq"
func main() {
    db, err := sql.Open(
        "postgres",
        "postgres://jon@localhost/evil?sslmode=disable")
}

И это явно «злой» вариант. Когда вы используете инициализацию, код, который работает магическим образом, полностью вне контроля разработчика. Best-практики не рекомендуют использовать функции инициализации — это неочевидные фичи, они запутывают код, и их легко спрятать в библиотеке.

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

package account
import (
    "fmt"
    "github.com/evil-go/example/registry"
)
type StubAccountService struct {}
func (a StubAccountService) GetBalance(accountId int) int {
    return 1000000
}
func init() {
    registry.Register("account", StubAccountService{})
}

Если вы захотите использовать аккаунт, то помещаете пустой импорт в вашей программе. Он не должен быть основным или связанным кодом, — он просто должен быть «где-то». Это магия!

package main
import (
    _ "github.com/evil-go/example/account"
   "github.com/evil-go/example/registry"
)
type Balancer interface {
    GetBalance(int) int
}
func main() {
    a := registry.Get("account").(Balancer)
    money := a.GetBalance(12345)
}

Если вы используете init-ы в своих библиотеках для настройки зависимостей, вы сразу увидите, что другие разработчики ломают голову, как эти зависимости были установлены, и как их изменить. И не будет никого мудрее вас.

Усложненная конфигурация


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

func main() {
    b, err := ioutil.ReadFile("account.json")
    if err != nil {
    fmt.Errorf("error reading config file: %v", err)
    os.Exit(1)
    }
    m := map[string]interface{}{}
    json.Unmarshal(b, &m)
    prefix := m["account.prefix"].(string)
    maker := account.NewMaker(prefix)
}
type Maker struct {
    prefix string
}
func (m Maker) NewAccount(name string) Account {
    return Account{Name: name, Id: m.prefix + "-12345"}
}
func NewMaker(prefix string) Maker {
    return Maker{prefix: prefix}
}

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

Если это кажется уж слишком «злым», используйте функцию init, чтобы загрузить файл свойств изнутри вашего пакета и установить значения самостоятельно. Может показаться, что вы сделали жизнь других разработчиков проще, но мы-то с вами знаем…

С помощью функции init вы можете определить новые свойства в глубине кода, и никто никогда не найдет их, пока они не попадут в продакшен, и все не отвалится, потому что чего-то не достанет в одном из десятков файлов свойств, необходимых для запуска. Если вы хотите еще больше «злой силы», вы можете предложить создать вики, чтобы отслеживать все свойства во всех библиотеках и «забывать» периодически добавлять новые. Как Хранитель свойств, вы становитесь единственным человеком, который может запустить программное обеспечение.

func (m maker) NewAccount(name string) Account {
    return Account{Name: name, Id: m.prefix + "-12345"}
}
var Maker maker
func init() {
    b, _ := ioutil.ReadFile("account.json")
    m := map[string]interface{}{}
    json.Unmarshal(b, &m)
    Maker.prefix = m["account.prefix"].(string)
}

Фреймворки для функциональности


Наконец, мы подошли к теме фреймворки vs библиотеки. Разница очень тонкая. Дело не только в размерах; у вас могут быть большие библиотеки и маленькие фреймворки. Фреймворк вызывает ваш код, в то время как вы сами вызываете код библиотеки. Фреймворки требуют, чтобы вы писали свой код определенным образом, будь то именование ваших методов по конкретным правилам, или чтобы они соответствовали определенным интерфейсам, или заставляли вас регистрировать ваш код во фреймворке. Фреймворки предъявляют свои требования ко всему вашему коду. То есть в общем, фреймворки командуют вами.

Go поощряет использование библиотек, потому что библиотеки компонуются. Хотя, конечно, каждая библиотека ожидает передачи данных в определенном формате, вы можете написать немного связующего кода, чтобы преобразовать вывод одной библиотеки в инпут для другой.
Фреймворки трудно заставить слаженно работать вместе потому, что каждый фреймворк хочет полного контроля над жизненным циклом кода. Часто единственный способ заставить фреймворки работать слаженно — это чтобы авторы фреймворков собрались вместе и явно организовали взаимную поддержку. А лучший способ использовать «зло фреймворков» для получения долгосрочной власти — написать собственный фреймворк, который используется только внутри компании.

Текущее и будущее зло


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

Комментарии (8)


  1. tumbler
    19.07.2019 13:57
    +1

    Крутая статья, но очень уж сложно превращать злые советы обратно в добрые.


  1. tumbler
    19.07.2019 14:02

    А перевод продолжения будет?


    1. ldanmer Автор
      19.07.2019 14:05
      +1

      Конечно


  1. masai
    19.07.2019 16:14

    Работать с Go — здорово, прежде всего потому, что за кодом очень легко следовать.

    Что значит «следовать за кодом»?


    1. ldanmer Автор
      19.07.2019 16:18
      +1

      Вероятно я не совсем адекватно перевел английское выражение «code is so easy to follow». Смысл в том, что разработчик, читающий код, легко может проследить что куда идет.


      1. masai
        19.07.2019 17:57

        А, вижу. Извините за оффтопик. Не сразу заметил, что это перевод. :) «Easy to follow» кроме дословного перевода ещё имеет значение «лёгкий в использовании», «практичный». Наверное, можно было как-то так перевести.


  1. TonyLorencio
    19.07.2019 17:20

    К сожалению, многие люди, приходящие в Go, в первую очередь начинают использовать именно "плохие" советы, потому что они напоминают им то, с чем они уже сталкивались. Немалая часть из этих людей так и не переучивается на "хорошие" советы, и появляются такие монстры всё-в-одном как, например, beego.


  1. tyderh
    20.07.2019 02:58

    Кстати, насчёт defer: случайно обнаружил очень забавный (и очевидный, если подумать) способ выстрелить себе в ногу: если defer-функция зависнет, то паника потеряется вместе с ней (не пройдёт выше в recover и не уронит программу).