10 непривычных моментов в Go для Java разработчика
- понедельник, 23 июня 2025 г. в 00:00:06
Несколько лет назад я начал добавлять Go в свой арсенал языков (будучи на тот момент Java разработчиком). Мне было очень непривычно. Более того, я принял язык не с первой попытки. Причём пришлось принять его больше из-за сложившихся обстоятельств, чем по собственному желанию.
Но прошло время, Go стал моим основным языком и, рискну сказать, любимым. В статье ниже расскажу, почему язык казался мне непривычным, какие парадигмы мне пришлось поменять в своей голове и почему во многом это оказалось более эффективно.
Уточню: статья ориентирована больше на тех, кто планирует перейти в Go, чем для опытных разработчиков.
Впервые я познакомился с Go в 2022-м году. Я слышал, что Go — это простой и лаконичный язык, на котором код получается более чистый, поддерживаемый и быстрый. В моей голове сформировался некий образ воображаемой производительности, стабильности и простоты в одном. Забегая вперёд: в конечном итоге это оказалось действительно так.
Я решил его изучить — и… после нескольких подходов бросил. Непривычный синтаксис, немного извращенная модель открытости и закрытости через заглавную букву, отсутствие наследования… Жуть!
Спустя ещё пару месяцев Go оказался наиболее подходящим инструментом для проекта (связан с криптовалютой) и в команде уже была экспертиза на Go. Язык взяли для проекта. Следовательно, Go пришлось учить вне зависимости от предпочтений. Все-таки вкус — это вкус, а работа — это работа.
Изучил документацию, прошёл официальную обучалку, познакомился с парой библиотек, написал несколько CRUD’ов и смирился. Полюбить язык пока не смог, но смирился и начал на нем регулярно писать.
Ещё спустя пару-тройку десятков тысяч строк кода заметил, что перестроился. 70% кода я писал на Go, он стал для меня привычным. Все сторонние утилиты, пет-проекты и маленькие proof of concept мне стало проще писать именно на Go (хотя раньше бы взял Java или Python). И вот, спустя несколько лет, доля Go в моей работе плавно переросла из ~10% до почти 100%.
В этой статье я решил поделиться моментами, которые лично для меня оказались непривычными при смене языка. В том числе, я пообщался со знакомыми разработчиками, которые тоже в последние годы меняли свой стек с Java \ C# на Go, и синхронизировал наши ощущения.
Содержание
Главный приоритет языка — наглядность (но практически всегда за счёт многословности)
Неявная имплементация интерфейсов и объявление в месте использования
Во время определения структуры не подсвечиваются ошибки, если не хватает части полей
Управление видимостью полей через заглавность первой буквы в названии поля
Ещё может быть интересно:
Мой open source проект для бекапа PostgreSQL баз данных (буду признателен звезде на GitHub)
В Go всё делается в угоду явности и читаемости. От обработки ошибок до валидаций данных в API роутах и явных транзакций. DI в Go сообществе не распространен, особенно в средне-маленьких проектах и микросервисах (хотя и существует Uber FX). Нет аннотаций, минимум синтаксического сахара.
Сюда же можно отнести подход с абстракциями: в Java принято сначала думать об абстракциях (правильные интерфейсы, разложить по классам, продумать фабрики). В Go принято не заморачиваться с абстракциями раньше времени, а делать минимально адекватную структуру и просто писать код. А абстракции вводить по мере необходимости. Наследования, кстати, тоже нет, только композиция и интерфейсы.
По поводу ошибок: это то, что в Go сразу бросается в глаза и что вносит существенную долю "явности". В Go нет "выбрасываемых" исключений (да и в целом блока try). Все ошибки возвращаются из функций как один из параметров. Исключение — если произошёл сбой и выбросилась паника (для бизнес-приложений считается антипаттерном во всех стайлгайдах).
Покажу несколько примеров, как увеличивается количество кода, но повышается явность при обработке ошибок.
Пример 1: чтение конфигурации.
Код на Java с одним try \ catch:
try {
String raw = Files.readString(Path.of("cfg.json"));
Config cfg = mapper.readValue(raw, Config.class);
cache.put("cfg", cfg);
return cfg;
} catch (IOException | JsonProcessingException ex) {
throw new IllegalStateException("cannot init config", ex);
}
Тот же код на Go:
raw, err := os.ReadFile("cfg.json")
if err != nil {
return nil, fmt.Errorf("read cfg: %w", err)
}
var cfg Config
if err := json.Unmarshal(raw, &cfg); err != nil {
return nil, fmt.Errorf("parse cfg: %w", err)
}
if err := cache.Set("cfg", cfg); err != nil {
return nil, fmt.Errorf("cache cfg: %w", err)
}
return &cfg, nil
С одной стороны, код на Java более компактный. С другой, в случае ошибки в Java коде, нам нужно будет смотреть в портянку stack trace и пытаться понять, какая из строчек выдала ошибку.
В Go ошибка сразу покажет, где именно и что сломалось (и опции не обратить внимание на ошибку — у нас нет). Ещё субъективный плюс: незнакомому с кодом читателю проще понять, что и где именно пошло не так.
Пример 2: транзакции.
Код на Java:
@Transactional
public void processPayment(Payment p) {
try {
accountService.debit(p);
journalRepository.save(p);
mq.send(new PaymentMessage(p));
} catch (Exception e) {
throw new PaymentFailed("processing error", e);
}
}
Код на Go:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := debit(tx, p); err != nil {
return fmt.Errorf("debit: %w", err)
}
if err := journalSave(tx, p); err != nil {
return fmt.Errorf("journal: %w", err)
}
if err := mq.Send(ctx, newPaymentMsg(p)); err != nil {
return fmt.Errorf("mq send: %w", err)
}
return tx.Commit()
Снова код на Java короче и лаконичнее. Код на Go — более длинный и очень явный. Без аннотаций. Транзакцию вызываем и прокидываем вручную. Ошибки из каждого метода обрабатываем вручную.
Кстати, отсутствие try уменьшает вложенность кода, он становится как бы "одноуровневым".
В Go есть интерфейсы по аналогии с Java. Например:
type PaymentGateway interface {
Charge(ctx context.Context, req ChargeRequest) (ChargeResponse, error)
}
Для его имплементации нам нужно создать структуру, в которой будут методы с такой же сигнатурой:
type StripeGateway struct {}
func (s *StripeGateway) Charge(ctx context.Context, req ChargeRequest,) (ChargeResponse, error) {
// implementation
}
Как видите, нет никаких слов implements
или чего-то, что указывает, что структура имплементирует интерфейс. Для меня по началу было очень странно, ведь нет явной связи между интерфейсом и реализацией. Нельзя зажать shift и перейти в интерфейс из структуры (на тот момент я использовал VS Code, в Goland от JetBrains можно).
Ещё меня смущала ситуация, когда меняешь название метода или параметры, а ошибку выбивает где-то в другом конце проекта. Там, где кто-то пытается использовать интерфейс. Я ожидал ошибки по типу "wrong interface implementation, method arguments mismatch" в месте определения структуры, а подсвечивалась ошибка в месте использования интерфейса.
Оказалось, это важная идея самого языка, которая связана с тем, что интерфейсы принято объявлять в месте использования, а не реализации.
В Java мы складываем рядом в одном пакете интерфейсы (например, PaymentGateway
) и их реализации (например, StripePaymentGateway
или PayPalPaymentGateway
).
В Go принято класть интерфейс в модуле, который его использует. Например, в пакете billing
или checkout
, где мы будем вызывать методы интерфейса.
Грубо говоря, интерфейс лежит в одной части проекта. Имплементации в другой. И между ними нет явной связи, если не просматривать DI граф. Неочевидно. Но это даёт бонус: уменьшение связанности. Легче подкладывать моки, легче тестировать, меньше зависимости модулей друг от друга. А ещё позволяет избегать циклических зависимостей между модулями (о чём ниже).
Правда важно не забывать, что пусть связи в коде становится меньше — логическая связанность никуда не девается.
Допустим, у нас есть следующая структура:
type BillingService struct {
stripeService *stripeService
receiptService *receiptService
}
Если при объявлении пропустить одно из значений — ошибки во время компиляции не будет. Например:
var billingService := BillingService{
stripeService: getStripeService(),
// there is no field, but it compiles fine
}
Поначалу я воспринимал объявление структуры выше как аналог конструктора в Java. В который всегда нужно передавать все параметры, иначе код не скомпилируется. Но нет. В Go объявление без передачи всех параметров будет валидным.
При этом, с большой вероятностью, при использовании receiptService
возникнет паника из-за обращения к nil
значению (по аналогии с NullPointerException
). А может, кстати, и не возникнуть — об этом следующий пункт.
Есть способ все-таки проверить передачу всех параметров на этапе компилации. Для этого нужно передавать значения не по названию поля, а через запятую:
var billingService := BillingService{
getStripeService(), // first param
// error: there should be second param
}
В этом случае компилятор подскажет, что не хватает ещё одного параметра. Именно такое определение считается правильным использовать при построении DI графа. Чтобы не забыть про новые зависимости по мере их добавления.
Предположим, у нас есть структура с методом (который обращается к ссылке на структуру).
type Person struct {}
func (p *Person) Speak() { // attention: there is a pointer *, not value
fmt.Println("Hey!")
}
Мы определим nil
переменную с типом этой структуры и вызовем метод у этой переменной. В Java такой код вызвал бы NullPointerException
. А в Go... зависит от. Если структура использует в методе *
ссылку на себя — проблемы не будет:
package main
import "fmt"
func main() {
var person *Person = nil
person.Speak()
}
Код выше будет работать и выведет Hey
, несмотря на то, что переменная person
nil. А теперь уберём *
у метода Speak
:
type Person struct {}
func (p Person) Speak() { // use Person instead of *Person
fmt.Println("Hey!")
}
И исполняем тот же код:
package main
import "fmt"
func main() {
var person *Person = nil
person.Speak()
}
Получаем ошибку:
panic: runtime error: invalid memory address or nil pointer dereference
Почему так — я понял спустя очень долгое время. После того как я прочитал книгу "Go: идиомы и паттерны проектирования" и узнал о разыменовании указателей. Книга нудная, практически справочник, но советую к прочтению хотя бы раз.
Суть в том, что при вызове метода со значением Go сначала разыменовывает указатель, чтобы скопировать структуру и передать её в метод. А при вызове метода со ссылкой, он просто передаёт сам указатель (хоть и nil
), не пытаясь его разыменовывать.
В Java есть аннотации, которые можно прикреплять к полям, методам и классам. Они очень сильно сокращают объем кода и прячут под собой много логики. Разработку на Spring и Hibernate без аннотаций я уже не могу себе представить.
Аннотации делают магию. Но… в какой-то момент у меня стало периодически возникать ощущение, что магии слишком много. Трудновато понять, где какой прокси кого вызывает и где какая рефлексия используется. Длинные полотна стектрейсов — туда же.
В Go аннотаций нет.
Вместо аннотаций есть "теги", которые не содержат в себе никакой логики. Можно взять структуру, написать теги для её полей. Затем передать структуру в какую-то функцию, которая извлечет эти теги и сделает что-то, основываясь на их содержании.
Например, можно написать вот такую структуру, которую мы ждём в API методе:
type User struct {
ID int64 `json:"id"`
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
}
Повторю: сами по себе теги ничего не делают и на этапе компиляции ничего не происходит.
Затем, мы можем передать эту структуру в метод, который декодирует JSON:
if err := c.ShouldBindJSON(&user); err != nil { // decode request body to struct
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
И уже этот метод извлечет теги и проверить каждое поле, исходя из того, что требует тег. Аналогично работают ORM и SQL конструкторы, которые определяют названия колонок и другие параметры через теги.
Но, так как теги не имеют своей логики, в Go не существует аннотаций по типу @ToString
, @Builder
, @Transactional
, @Service
и многих-многих других. С одной стороны, всё делаем руками и больше кода. С другой стороны — больше наглядности и контроля.
Важно: я не говорю, что аннотации — это плохо. Это очень даже хорошо. Просто у языков разные подходы для разных задач со своими плюсами, минусами и компромиссами.
В Java строка представляет собой неизменяемую последовательность char
. В Go тип string
— это неизменяемый срез байтов
(UTF-8 по умолчанию). Для представления отдельного символа используется rune
— 32-битовое целое, содержащее символ в Unicode.
Следовательно, когда работаем со строками, нужно аккуратно использовать len
и посимвольно обрезать строки. Особенно, когда используем не английский язык и нестандартные символы (например, смайлики).
Разберём на примере. Берём английское слово:
s := "hello" // 5 chars, 5 bytes
fmt.Println(len(s)) // 5 🔢 (bytes)
fmt.Println(len([]rune(s))) // 5 🔣 (chars)
fmt.Println([]rune(s)) // [104 101 108 108 111]
Всё логично. А теперь берём русское слово и пару смайликов:
s := "Привет🌍😊" // 8 chars, 20 bytes
fmt.Println(len(s)) // 20 🔢 (bytes)
fmt.Println(len([]rune(s))) // 8 🔣 (chars)
fmt.Println([]rune(s)) // [1055 1088 1080 1074 1077 1090 127757 128522]
Количество символов и байтов разошлось. Если бы мы обрезали или меняли строку без конвертации в rune
, мы бы натолкнулись на ошибки и некорректные данные (прям как я :)).
В Java существует несколько общепринятых кодстайлов и бесконечное множество настроек в IDE (как минимум, в IDEA). Обычно, в проектах берётся общепринятый кодстайл (от Oracle или Google) и добавляются локальные правила форматирования в самой IDE. Затем этот конфиг передается между членами команды.
Одной из причин, почему я захотел попробовать Go — это заявленное единообразие. Существует встроенная утилита go fmt
, которая идёт вместе с языком. Весь код на Go форматируется ей. Меня это заинтриговало!
Оказалось… Всё не так просто. Вот один и тот же код, вручную отформатированный по-разному:
func SomeMethod(value1 string, value2 string, value3 string) error {
return nil
}
func SomeMethod(
value1 string,
value2 string,
value3 string,
) error {
return nil
}
func SomeMethod(value1 string,
value2 string,
value3 string,
) error {
return nil
}
Все эти варианты стандартный go fmt
считает валидными и не изменяет... А ещё go fmt
не умеет ограничивать длину строк и правила переноса, нет правил для списка импортов и много чего ещё.
Поэтому о чем-то в командах все-таки приходится договариваться, для чего-то используются сторонние форматировщики. Самый популярный инструмент — это golangci-lint. Он умеет аккумулировать в себе другие форматировщики (и линтеры).
Это один из пунктов, который меня сильно отталкивал в Go. Моё мышление с большим трудом перестраивалось на такую логику.
В Java область видимости определяется модификаторами public
/ private
/ protected
. Обычные английские слова, всё понятно. Такой подход используется в большом количестве популярных языков.
В Go область видимости определяется первым символом имени: заглавная буква делает поле публичным, строчная — приватным внутри модуля.
Вот пример публичной (точнее экспортируемой в терминах Go) структуры с публичным и приватными методами:
type Person struct {} // public struct
func (Person) Talk() {} // public method
func (Person) speak() {} // private method
Вот приватная структура с приватным методом (а ещё структуры можно делать с маленькой буквы, а не как классы в Java):
type person struct { /*...*/ } // private struct
func (person) talk() {} // private method
Вот публичная и приватные функции:
func SomeFunc() { /*...*/ } // public func
func someFunc() { /*...*/ } // private func
Хороший или плохой этот подход — вопрос привычки. Мне очень долго было непривычно, но вопрос вкуса. Со временем привык.
Эта часть Go мне сильно нравится. Она заставляет писать более чистый код и продумывать корректную иерархию компонентов и модулей.
В Java мы можем импортировать объекты из разных пакетов друг другу. Например:
// файл: src/a/A.java
package a;
import b.B;
public class A {
private B b;
}
// файл: src/b/B.java
package b;
import a.A;
public class B {
private A a;
}
В этом примере оба класса взаимно ссылаются друг на друга через поля, а пакеты a
и b
импортируют классы друг друга. Компилятор Java такое разрешает (пусть нам и нужно помнить о ленивой инициализации значений).
В Go так сделать не выйдет. Например:
// a.go
package a
import "project/b" // a → b
// b.go
package b
import "project/a" // b → a ❌
В этом случае мы получим ошибку:
import cycle not allowed
Это одна из тех практик, с которой я до конца не смирился.
В Go принято использовать лаконичные или однобуквенные идентификаторы (ctx
, db
, err
, s
, r
). Особенно в небольших функциях. Например, вот пример идиоматичного кода:
func GetUser(c *gin.Context) {
id := c.Param("id")
u, err := userRepo.FindByID(id)
if err != nil {
c.JSON(404, gin.H{"error": err.Error()})
return
}
c.JSON(200, u)
}
context
сокращается до c
. user
до u
. Аналогично, много общепринятых соглашений по типу ResponseWritter
-> w
, request
-> r
, payment
-> p
и т.д. В "инфраструктурном" или "шаблонном" коде — это нормально. А вот в бизнес-логике уже не всё так хорошо, так как читаемость кода снижается.
Я стараюсь использовать более понятные названия. Особенно в бизнес-логике. Например, те же понятия в gin
и совсем очевидные вещи — можно сократить до одной буквы (хотя тот же req
и res
, имхо, понятнее).
Но если речь доходит до бизнес-логики, я все же советую не писать вот так:
func (s *BillingService) Pay(payments *[]Payment, b *Bank) (*Receipt, error) {
r := &Receipt{ID: uuid.New(), Amounts: []int{}}
for _, p := range *payments {
r.Amounts = append(r.Amounts, p.Amount)
}
if err := b.Debit(p.Amount); err != nil {
return nil, err
}
return r, nil
}
А давать более понятные и длинные названия:
func (s *BillingService) Pay(payments *[]Payment, bank *Bank) (*Receipt, error) {
receipt := &Receipt{ID: uuid.New(), Amounts: []int{}}
for _, payment := range *payments {
receipt.Amounts = append(receipt.Amounts, payment.Amount)
}
if err := bank.Debit(p.Amount); err != nil {
return nil, err
}
return receipt, nil
}
Это даёт следующие бонусы:
снижает когнитивную нагрузку читающего (не нужно бегать вверх глазами, чтобы понять, что значит эта буква);
меньше шанс ошибиться в названии или что-то перепутать;
если метод длинный — позволяет понять в любой части метода за что переменная отвечает.
Разумеется, такие соглашения зависят от команды и принятых практик. Если в команде принят стандартный подход с короткими именами, не нужно делать по-другому (как минимум, не договорившись заранее об этом).
Собственно, выше я постарался описать основные моменты, которые были непривычны для меня (да и не только для меня). Языки достаточно сильно различаются. Я бы сказал, что Go для меня — это что-то среднее между строгостью Java и гибкостью Python.
Время показало, что для меня изучение Go того стоило. Все-таки язык — это, прежде всего, инструмент. Go для меня стал эффективным инструментом. Который мне нравится, даёт быстрый и стабильный результат. Мне очень нравится простота языка и его наглядность, пусть в ущерб синтаксическому сахару и за счёт чуть большего объема кода. А ещё с ним хорошо дружит ИИ (в особенности Cursor IDE).
Надеюсь, моя статья подтолкнет хотя бы несколько разработчиков к изучению Go и упростит понимание некоторых вещей.
Ну и, как полагается, у меня есть Telegram-канал, в котором я рассказываю про разработку, развитие SaaS-сервисов и управление IT проектами. В том числе о проблемах, которые возникают. Там же я выкладываю ссылки на новые статьи на Habr'e.
Другие мои статьи:
А ещё я выложил свой проект для PostgreSQL бекапов в open source, буду очень признателен звезде на GitHub.