О Go

2019-02-03

Поковырялся я в Go. Который Golang от Google.

Подвернулся хипстерский стартаперский «legacy» проект, где «backend» был написан на Go. Так себе написан. Echo, конечно, неплохой веб фреймворк. Но он примерно как Flask. То есть маршруты, биндинги, JSONы, middleware и прочие веб прелести присутствуют. Для микросервисов вроде достаточно. Но это лишь сторона, обращённая к веб. А с другой стороны: клиенты к другим сервисам, базам данных и прочим — берите и изобретайте сами.

Простота написания первых хэндлеров способствует тому, что начинающие джуниоры начитают писать в «стиле PHP». В каждой хэндлер-функции всё вперемешку: приём данных, валидация, несколько SQL запросов, записанных прямо тут же, формирование ответа, выдача ошибок. И таких функций — полсотни уже. Дублирование кода? А что плохого? SOLID? Не, не слышали.

В общем, проблемы этого проекта не в языке. Но хочется поговорить именно о Go. В первый раз плотно с ним столкнулся. И... поражён.

Gopher

На localhost Go тоталитарен. Всё, что имеет отношение к Go, должно быть в $GOPATH. Это обязательная переменная окружения, которая указывает на некий каталог. Обычно на ~/go. И в $GOPATH/src должны лежать все ваши исходники, а в $GOPATH/bin будут складываться результаты компиляции. Ну ладно, сам компилятор может быть просто установлен в системе: /usr/bin/go. Если у вас несколько проектов, которые не должны пересекаться по исходникам, вам придётся для каждого завести свой $GOPATH и не забывать его переключать.

В Go нет библиотек. В Go нет линковки. Никаких бинарников, или архивов, или заголовочных файлов, которые можно подключить к проекту. Никакого контроля версий. Всё просто. Компилятор Go просто собирает некое подмножество *.go файлов из $GOPATH/src в один бинарник, который кладёт в $GOPATH/bin.

В $GOPATH/src каждый каталог представляет собой пакет (package). Пакет — это сколько угодно файлов .go в одном каталоге. Каждый файл должен начинаться со строки: package package_name.

Пакет с именем "main" — особенный. Он представляет собой программу, бинарник, который нужно собрать. Пакетов с именем "main" в $GOPATH/src может быть несколько, в разных каталогах. Значит, из этого дерева исходников можно собрать несколько бинарников.

Давайте создадим файл $GOPATH/src/echo_sample/main.go.

package main

func main() {
}

Это — минимальная программа на Go. Она ничего не делает. Но её можно скомпилировать и запустить.

$ cd $GOPATH/src/echo_sample
$ go install
$ $GOPATH/bin/echo_sample

Нехилый бинарничек на мегабайт получился.

$ ls -lh $GOPATH/bin
total 1.1M
-rwxrwxr-x 1 gelin gelin 1.1M Feb  2 15:40 echo_sample

Это потому что в Go нет динамических библиотек. Всё компилируется в один бинарник. Вон, в Terraform плагины — это отдельные сервера, с которыми главная программа общается по HTTP.

Но мы хотим веб сервер. Поэтому импортируем Echo.

import (
    "github.com/labstack/echo"
)

Пока у нас нет исходников Echo для компиляции. Но их легко получить.

$ cd $GOPATH/src/echo_sample
$ go get
# echo_sample
./main.go:4:2: imported and not used: "github.com/labstack/echo"

Команда go get скачала, внезапно, с самого GitHub, все исходники Echo, и все его зависимости.

$ tree -L 3 $GOPATH/src
/home/gelin/work/bitbucket/blog/go/go/src
├── echo_sample
│   └── main.go
├── github.com
│   ├── labstack
│   │   ├── echo
│   │   └── gommon
│   ├── mattn
│   │   ├── go-colorable
│   │   └── go-isatty
│   └── valyala
│       └── fasttemplate
└── golang.org
    └── x
        └── crypto

13 directories, 1 file

Причём это не просто исходники, а полноценные склонированные Git репозитории. Можно пулреквестить прямо оттуда :)

$ cd $GOPATH/src/github.com/labstack/echo
$ ls -a
.        bind_test.go     echo.go        _fixture        .github     go.sum         LICENSE   middleware   response_test.go  .travis.yml
..       context.go       echo_test.go   .git            .gitignore  group.go       log.go    README.md    router.go
bind.go  context_test.go  .editorconfig  .gitattributes  go.mod      group_test.go  Makefile  response.go  router_test.go
$ git status
On branch master
Your branch is up to date with 'origin/master'.

nothing to commit, working tree clean
$ git remote -v
origin  https://github.com/labstack/echo (fetch)
origin  https://github.com/labstack/echo (push)

Получается, что $GOPATH/src — это множество вложенных репозиториев. Из которых всё и собирается. И, само собой, берётся свежий master. Не очень хорошо для повторяемой сборки. Сам по себе Go не позволяет задать конкретную версию зависимостей.

Но в Go, начиная с версии 1.5, то есть уже довольно давно, есть поддержка вендоринга. Идея в том, что на уровне любого каталога в $GOPATH/src можно создать подкаталог vendor. А внутри него можно воссоздать часть дерева исходников. И это вендорное поддерево будет переопределять аналогичные пакеты в основном дереве.

Подкаталог vendor находится в репозитории данного пакета и подконтролен ему. Можно туда положить нужную правильную версию библиотеки. А для пущего удобства и чтобы не держать в своём репозитории полную копию исходников, можно воспользоваться инструментами управления вендорингом. Например, govendor.

$ cd $GOPATH/src/echo_sample
$ govendor init
$ govendor add +e
$ govendor list
 v  github.com/labstack/echo
 v  github.com/labstack/gommon/color
 v  github.com/labstack/gommon/log
 v  github.com/mattn/go-colorable
 v  github.com/mattn/go-isatty
 v  github.com/valyala/bytebufferpool
 v  github.com/valyala/fasttemplate
 v  golang.org/x/crypto/acme
 v  golang.org/x/crypto/acme/autocert
pl  echo_sample
  m golang.org/x/sys/unix
$ ls
main.go  vendor
$ ls vendor
github.com  golang.org  vendor.json
$ head vendor/vendor.json
{
        "comment": "",
        "ignore": "test",
        "package": [
                {
                        "checksumSHA1": "AlL1am5Xe3ouiyhL8OLlTDSUkG8=",
                        "path": "github.com/labstack/echo",
                        "revision": "bc28fceaf38c6ca5ce7d749c22bbd2ac0405170c",
                        "revisionTime": "2019-02-01T14:33:56Z"
                },

Зависимости помещаются в каталог vendor. Конкретные версии зависимостей, с точностью до ревизии в Git репозитории, записываются в файл vendor.json. Это похоже на package.json в мире NPM. Достаточно иметь файл vendor.json, а нужные зависимости в нужных ревизиях потом можно восстановить с помощью govendor.

С зависимостями разобрались. Пора делать сервер.

package main

import (
    "fmt"
    "github.com/labstack/echo"
    "net/http"
)

func main() {
    e := echo.New()
    e.GET("/", helloHandler)
    _ = e.Start(":8080")
}

func helloHandler(c echo.Context) error {
    name := c.QueryParam("name")
    message := fmt.Sprintf("Hello, %s!", name)
    return c.JSON(http.StatusOK, echo.Map{
        "message": message,
    })
}

Нам понадобится импортировать стандартные пакеты fmt и net/http. В первом — уйма функций форматирования вывода, в стиле сишных *printf, но чуть мощнее, и форматированного ввода. Во втором — стандартный HTTP клиент, нам оттуда нужны коды HTTP ответов, просто для красоты.

Проимпортированный пакет "github.com/labstack/echo" доступен по имени echo. Потому что там в файлах написано package echo. Особых конфликтов имён при импорте не возникает, потому что можно проимпортировать и под другим именем. Ну а длинное имя, что в импорте в кавычках писать, — это уникальный адрес репозитория.

В пакете у нас есть функция echo.New(). Она возвращает тип Echo. Точнее, с указанием пакета, echo.Echo. Точнее, указатель на него: *echo.Echo.

Для сохранения указателя в локальную переменную e используется операция :=. Она делает сразу три вещи: определяет переменную, выводит тип переменной, присваивает значение переменной. Так можно только для локальных переменных. И эта магия используется в Go повсеместно.

Все эти записи эквивалентны:

// 1
var e *echo.Echo
e = echo.New()
// 2
var e = echo.New()
// 3
e := echo.New()

Как видите, в Go типы указываются как в Pascal, или Kotlin, или Scala — после имени переменной, пусть и без двоеточия.

Типы. Всё странно с ними в Go.

Есть примитивные типы. Целые числа разных длин и значности: int8, int16, int32, int64, uint8, uint16, uint32, uint64. А есть и просто int, реальная длина которого зависит от платформы, как в C. Но нельзя просто так взять и присвоить один int другому. Всегда требуется явная конвертация значения. Это касается и просто int, который вроде на 64-битной платформе должен полностью совпадать с int64.

var i int
var i64 int64
i64 = 0x7FFFFFFFFFFFFFFF
i = i64     // cannot use i64 (type int64) as type int in assignment
i = int(i64)    // works

Литералы в Go не имеют типа. Компилятор сам генерирует значение нужной длины и типа, в зависимости от того, какой переменной его нужно присвоить. Поэтому не нужно писать 1L или 1.0, достаточно написать 1.

Вещественные числа: float32 и float64. Тут явно нужно разрядность указывать.

Есть комплексные числа: complex64 и complex128, соответственно, из двух float32 или float64.

Есть bool, который принимает значения true или false. Естественно, только bool может быть результатом проверки в if.

Есть string. Это встроенный тип. Строка может содержать любые символы Unicode. Фактически хранится массив байт в UTF-8. Строки неизменяемы. Строка считается примитивным типом и всегда передаётся по значению. Раз это вполне себе значение, строка не может быть nil. Тем не менее, у строки, так же как у всех встроенных типов, есть «нулевое» значение по умолчанию — пустая строка ("").

Указатели. Они есть. Они нужны, если нужно передать значение по ссылке. Или чтобы представить nil, так записывается местный null.

Для доступа к полям структуры или к методам указатели не нужно разыменовывать. Операция . (точка) прекрасно работает как непосредственно со значениями, так и с указателями. Так что, указатель это или нет, становится важно только при передаче значения в функцию или из функции.

Структуры. Самый часто используемый тип данных. Типа классы в Go. Ну по аналогии с тем, как структуры C стали классами C++. В структурах задаются поля. Всех возможных типов.

Например, тот же echo.Echo — это структура.

type (
    // Echo is the top-level framework instance.
    Echo struct {
        StdLogger        *stdLog.Logger
        colorer          *color.Color
        premiddleware    []MiddlewareFunc
        middleware       []MiddlewareFunc
        maxParam         *int
        router           *Router
// ...

На структуры, а на самом деле, на любые именованные типы тоже, можно навесить методы.

// GET registers a new GET route for a path with matching handler in the router
// with optional route-level middleware.
func (e *Echo) GET(path string, h HandlerFunc, m ...MiddlewareFunc) *Route {
    return e.Add(http.MethodGet, path, h, m...)
}

Получатель (receiver) явно указывается в скобках перед именем метода. И ему вовсе не обязательно называться this. Получателем может быть и указатель. Если у вас переменная имеет тип-значение, то к ней можно применить все методы, навешанные на это значение. Если же у вас переменная-указатель, то можно вызвать методы, навешанные и на тип-значение, и на тип-указатель.

Этот receiver — не синтаксический сахар. Это не другой способ указать первый параметр функции. Это не extension функции, как в Kotlin или C#. Это действительно методы. Просто их можно определить в другом файле, примерно как в C++. Но в том же пакете.

Имена методов в Go часто начинаются с большой буквы. Это не следование соглашениям C#. Это — права доступа. Все объявления пакета: переменные, функции, структуры, поля структур — будут видны из других пакетов только если их имена начинаются с большой буквы. Всё, что с маленькой буквы, — приватно для этого пакета. Не сильно страшное правило, жалко, не работает для китайского языка. Чаще всего вам придётся работать со структурами и функциями других пакетов, они публичные, поэтому — с большой буквы.

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

type Result struct {
    Success bool `json:"success"`
    Message string `json:"message"`
}

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

Да, в Go есть рефлекшен. И он частенько используется. Хотя бы для компенсации отсутствия generics.

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

type Result struct {
    Success bool `json:"success"`
}

type MessageResult struct {
    Result
    Message string `json:"message"`
}

И это никоим образом не наследование. В Go вообще нет наследования. И, соответственно, нет иерархии типов. Каждый тип — сам по себе. Это называется «ортогональность».

В Go есть два способа изменять поведение во время выполнения: функции и интерфейсы.

Функции. На Go вполне можно писать в функциональном стиле. Замыкания есть. Анонимные функции есть. Функции можно передавать как параметры функциям. Функции можно возвращать из функций.

Вот те же хэндлеры и middleware в Echo:

type (
// ...
    // MiddlewareFunc defines a function to process middleware.
    MiddlewareFunc func(HandlerFunc) HandlerFunc

    // HandlerFunc defines a function to serve HTTP requests.
    HandlerFunc func(Context) error
// ...

Интерфейсы. Как и положено, интерфейс объявляет набор методов, доступных для объектов, реализующих этот интерфейс.

Вот, например, стандартный интерфейс в пакете fmt:

type Stringer interface {
    String() string
}

В Go нет ключевого слова implements. И вообще нет возможности явно сказать, что какой-то тип реализует интерфейс. Просто, если у типа есть все методы, объявленные в интерфейсе, он реализует интерфейс. И можно в переменную типа интерфейса, присвоить значение или указатель этого типа.

func (r Result) String() string {
    return fmt.Sprintf("success: %v", r.Success)
}

func println(s fmt.Stringer) {
    fmt.Println(s)
}

Интерфейсы и их реализации — ортогональны. Идея в том, чтобы делать интерфейсы максимально маленькими, и объявлять их в месте использования. Да, буковка «I» в SOLID. И не мучать множество реализаций необходимостью тщательно следить за тем, какие интерфейсы реализуются. Ну поменялся интерфейс, ну не будет этот тип теперь его реализовывать, ну и не надо. Всё будет проверено либо компилятором, либо выяснится при приведении типов в рантайме. Строгая утиная типизация.

Приведение типов работает интересно. Можно просто попробовать привести что угодно к нужному типу.

Это просто не скомпилируется, если компилятор может проверить типы.

something := Something{}
// invalid type assertion: something.(fmt.Stringer) (non-interface type Something on left)
s := something.(fmt.Stringer)

Это скомпилируется, если лишить компилятор сведений о типах. Но в рантайме случится паника (panic).

var something interface{}
something = Something{}
// panic: interface conversion: main.Something is not fmt.Stringer: missing method String
s := something.(fmt.Stringer)

Да, в Go универсальный тип для всего, включая также nil, — это interface{}. Его специально называют «empty interface».

А если слева от присваивания указать два значения, то паники не будет. Будет проверка, можно ли привести это значение к данному типу.

s, ok := something.(fmt.Stringer)
if !ok {
    fmt.Println("not Stringer")
}

Подобная же проверка работает и со встроенным типом map. Причем как для проверки существования ключа, так и для проверки, что значение по данному ключу приводится к нужному типу.

m := make(map[string]interface{})
m["one"] = 1
v, ok := m["one"]       // v: 1, ok: true
v, ok = m["two"]        // v: <nil>, ok: false
v, ok = m["one"].(string)   // v: , ok: false

Да, в Go функции могут возвращать более одного значения. Хотя эти проверки — вовсе не функции, а, скорее, конструкции языка. Так вот, функции могут возвращать более одного значения. И на этом построена концепция обработки ошибок в Go.

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

Если какая-то функция может завершиться с ошибкой, она возвращает два значения: результат выполнения и ошибку. Если всё хорошо, будет результат, а ошибка будет nil. Если всё плохо, результат будет nil или нулевое значение, а в ошибке будет написано, что не так.

Аналогичный подход использовался в JavaScript в эпоху колбэков. Только в Go результат и ошибка, это не параметры колбэка, а нормальные возвращаемые значения. Аналогичный подход используется в Rust, где функции возвращают монаду Result, в которой, соответственно, есть либо результат, либо ошибка.

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

Вот пример из того же Echo. Тут эксплуатируются сразу несколько синтаксических особенностей Go.

func (ln tcpKeepAliveListener) Accept() (c net.Conn, err error) {
    if c, err = ln.AcceptTCP(); err != nil {
        return
    } else if err = c.(*net.TCPConn).SetKeepAlive(true); err != nil {
        return
    } else if err = c.(*net.TCPConn).SetKeepAlivePeriod(3 * time.Minute); err != nil {
        return
    }
    return
}

Выходные значения могут иметь имя. Тогда можно в теле функции присвоить им значения, а потом просто сделать return. Почти как в Pascal.

if, как вы заметили, пишется без круглых скобок.

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

В Go неиспользуемая локальная переменная считается ошибкой компиляции. Поэтому, если уж вы получили этот err, то вам нужно будет что-то с ним сделать. Как минимум сравнить с nil.

Но если вы хотите проигнорировать значение ошибки, да и любое другое возвращаемое значение, можно использовать «переменную» _.

m := make(map[string]interface{})
v, _ := m["one"]

c, _ = ln.AcceptTCP()

Само собой, будьте осторожны. Если вы проигнорировали ошибку, то наверняка полученное значение может оказаться nil, и вы запросто словите "panic: runtime error: invalid memory address or nil pointer dereference". Да, NPE (в данном случае NPD) в Go имеется. И никакого контроля на nullability в языке не предусмотрено.

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

Вот кусочек из пакета net/http. Оказывается, сервер находится там, а Echo — это лишь приятная обёртка, с роутингом и поддержкой JSON.

// Serve a new connection.
func (c *conn) serve(ctx context.Context) {
    // ...
    defer func() {
        if err := recover(); err != nil && err != ErrAbortHandler {
            const size = 64 << 10
            buf := make([]byte, size)
            buf = buf[:runtime.Stack(buf, false)]
            c.server.logf("http: panic serving %v: %v\n%s", c.remoteAddr, err, buf)
        }
        // ...
    }()
    // ...
    serverHandler{c.server}.ServeHTTP(w, w.req)
    // ...
}

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

Здесь у нас используется встроенная функция recover(). Она отменяет панику и возвращает её причину, с которой можно что-то сделать.

Gopher

В Go почти не пишут точку с запятой. Тут, как в JavaScript, компилятор сам расставляет точки с запятой. Но делает это немного глючно. В результате, например, нельзя перенести открывающую круглую или фигурную скобку на новую строку, чтобы писать в стиле C. Ибо компилятор воткнёт в конец предыдущей строки точку с запятой, что сломает синтаксис. Поэтому спора о том, где открывать скобку, в Go нет.

В Go нет и других споров по поводу стиля написания кода. Потому что есть gofmt. Эта штуковина автоматически форматирует файлы исходников на Go. Как это предписано авторами языка. Достаточно запустить в своём пакете go fmt. А IDE сами автоматически форматируют, Idea делает это перед коммитом. И нет никаких споров.

Кстати, отступ в Go обозначается символом табуляции.

С одной стороны, Go — язык простой. Никакой перегрузки операторов. Никакой перегрузки методов и функций, диспетчеризация вызовов делается исключительно по имени функции. Динамическое связывание используется только для интерфейсов. Никакой поддержки иммутабельности. Всё ради эффективности компилятора. И компилятор действительно очень-очень быстрый.

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

С другой стороны, в Go притащили несколько мощных высокоуровневых концепций.

Тут есть сборщик мусора. В скомпилированный бинарник, ясен пень, включён рантайм Go. И там есть сборщик мусора.

В Go есть горутины (goroutine) и каналы (channel).

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

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

В Go горутина не являетcя объектом. Это просто способ запуска функции (в том числе анонимной). Горутины нельзя явно подсчитать, нельзя явно остановить. Всё управление потоками спрятано от разработчика.

Горутины могут взаимодействовать через разделяемые данные. И тут потребуются примитивы синхронизации. И они есть в пакетах sync и sync/atomic.

Но интереснее взаимодействие через каналы. Это очень похоже на ZeroMQ сокеты. Некий, опциально буферизированный, поток структурированных данных (или сообщений). Одна горутина туда пишет структуры, и блокируется, если буфер заполнен. Другая горутина оттуда читает, и блокируется (опциально), если читать нечего. Можно наворотить красивые параллельные системы, почти акторы. И это всё — часть языка.

Вот из того же net/http сервера:

func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {
    // ...
    done := make(chan struct{})
    // ...
    panicChan := make(chan interface{}, 1)
    go func() {
        defer func() {
            if p := recover(); p != nil {
                panicChan <- p
            }
        }()
        h.handler.ServeHTTP(tw, r)
        close(done)
    }()
    select {
    case p := <-panicChan:
        panic(p)
    case <-done:
        // ...
        w.WriteHeader(tw.code)
        w.Write(tw.wbuf.Bytes())
    // ...
    }
}

Ладно. Такого уровня Go-fu я ещё не достиг. Хотя Телеграм-бот на Go выглядит соблазнительно просто.

Что там наш веб сервер? Работает?

$ $GOPATH/bin/echo_sample

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.0.0
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:8080

Действительно работает.

$ curl "http://localhost:8080?name=Go"
{"message":"Hello, Go!"}

Один единственный бинарник размером 7.3 мегабайта, который будет работать почти в любом 64-битном Линуксе просто так, без установки всяких зависимостей, интерпретаторов или виртуальных машин. Это заметно меньше, чем аналогичный «Hello, world!» на Spring Boot. А при желании, можно собрать такой же и для Windows, и для FreeBSD. Не удивительно, что Go любят вся всяких мелких утилит, которые должны просто работать везде.

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

Вселенная Go вообще выглядит довольно изолированной. Это всё — pure Go.