MidТеория6 min

Чистый код на Go

Именование, организация кода, комментарии, структура проекта и конвенции Go

Чистый код в Go -- это не просто форматирование. Это набор практик, которые делают код читаемым, поддерживаемым и предсказуемым. Go-сообщество уделяет этому особое внимание, потому что "gofmt's style is no one's favorite, yet gofmt is everyone's favorite".

Именование

Правило длины имён

В Go длина имени пропорциональна размеру области видимости:

// Short scope → short name
for i, v := range items { ... }
if err != nil { ... }
func (s *Server) handle(w http.ResponseWriter, r *http.Request) { ... }

// Medium scope → medium name
func processOrders(ctx context.Context, orders []*Order) error { ... }

// Long scope (package-level, exported) → descriptive name
type ConnectionPool struct { ... }
func NewHTTPClientWithRetries(cfg Config) *Client { ... }

Аббревиатуры

Общепринятые аббревиатуры пишутся одним регистром:

// Good
type HTTPClient struct{}   // not HttpClient
type URLParser struct{}    // not UrlParser
type XMLDecoder struct{}   // not XmlDecoder
func ServeHTTP() {}        // not ServeHttp
var userID string          // not userId (ID is abbreviation)

// Also good for unexported
var httpClient *http.Client
var xmlData []byte

Избегайте stuttering (заикания)

// Bad: package name repeats in type name
package user
type UserService struct{}  // user.UserService -- stutters!
type UserRepository struct{}

// Good: package name is part of the call
package user
type Service struct{}      // user.Service -- clean!
type Repository struct{}   // user.Repository

// Bad
package http
func HTTPServe() {}  // http.HTTPServe

// Good
package http
func Serve() {}      // http.Serve

Булевы переменные и функции

// Good: reads as English sentence
if user.IsActive() { ... }
if order.HasItems() { ... }
if cache.Contains(key) { ... }
var connected bool
var verbose bool

// Bad: confusing double negation or unclear meaning
if !user.IsNotActive() { ... }  // double negative
var isFlag bool                  // redundant "is" for variable

Длина функций

Go-сообщество рекомендует стремиться к функциям до 30 строк. Длинные функции -- сигнал о нарушении SRP.

// Bad: 80+ lines, does too many things
func ProcessOrder(ctx context.Context, order *Order) error {
    // validate order (20 lines)
    // calculate totals (15 lines)
    // apply discounts (20 lines)
    // save to database (10 lines)
    // send notification (15 lines)
    // update metrics (5 lines)
    return nil
}

// Good: decomposed into focused functions
func ProcessOrder(ctx context.Context, order *Order) error {
    if err := order.Validate(); err != nil {
        return fmt.Errorf("validating order: %w", err)
    }

    total := calculateTotal(order.Items)
    total = applyDiscounts(total, order.Coupons)
    order.Total = total

    if err := s.repo.Save(ctx, order); err != nil {
        return fmt.Errorf("saving order: %w", err)
    }

    s.notifyOrderPlaced(ctx, order) // fire and forget

    return nil
}

func calculateTotal(items []Item) decimal.Decimal { ... }
func applyDiscounts(total decimal.Decimal, coupons []Coupon) decimal.Decimal { ... }

Комментарии

Godoc-конвенции

// Package user provides user management functionality including
// registration, authentication, and profile operations.
package user

// User represents a registered user in the system.
// Zero value is not valid; use New to create instances.
type User struct {
    id    string
    name  string
    email string
}

// New creates a User with the given name and email.
// It generates a new UUID v7 as the user ID.
// Returns error if name is empty or email is invalid.
func New(name, email string) (*User, error) {
    // ...
}

Правила:

  • Комментарий начинается с имени элемента: // User represents...
  • Полные предложения с точкой в конце
  • Первое предложение -- краткое описание (используется в go doc)
  • Объясните зачем и контракт, а не как

Когда комментировать

// Good: explains WHY, not WHAT
// retryDelay uses exponential backoff to avoid thundering herd
// when multiple clients reconnect after an outage.
func retryDelay(attempt int) time.Duration {
    return time.Duration(1<<attempt) * time.Second
}

// Good: documents non-obvious behavior
// Close blocks until all pending writes are flushed.
// It is safe to call Close multiple times.
func (w *Writer) Close() error { ... }

// Bad: comment restates the code
// increment counter by 1
counter++

// Bad: obvious comment
// Check if error is nil
if err != nil { ... }

TODO и FIXME

// TODO(username): add rate limiting before launch
// FIXME(username): race condition when concurrent writes
// HACK(username): temporary workaround for upstream bug #1234

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

Форматирование ошибок

Go имеет чёткие конвенции для сообщений об ошибках:

// 1. Lowercase first letter
fmt.Errorf("connecting to database: %w", err)  // Good
fmt.Errorf("Connecting to database: %w", err)  // Bad

// 2. No trailing punctuation
fmt.Errorf("invalid email format")     // Good
fmt.Errorf("Invalid email format.")    // Bad

// 3. Action context, not "failed to"
fmt.Errorf("reading config file: %w", err)    // Good
fmt.Errorf("failed to read config file: %w", err)  // Bad (redundant)

// 4. Include relevant data
fmt.Errorf("parsing user %q: %w", username, err)  // Good

// 5. Use %w for wrapping (supports errors.Is / errors.As)
fmt.Errorf("querying user %s: %w", id, err)

Пример цепочки ошибок:

// Result: "starting server: binding address :8080: listen tcp :8080: address already in use"
func startServer(addr string) error {
    if err := bindAddress(addr); err != nil {
        return fmt.Errorf("starting server: %w", err)
    }
    return nil
}

func bindAddress(addr string) error {
    if err := net.Listen("tcp", addr); err != nil {
        return fmt.Errorf("binding address %s: %w", addr, err)
    }
    return nil
}

Организация пакетов

Domain-driven (рекомендуется)

myapp/
├── user/
│   ├── user.go         # User type, business logic
│   ├── store.go        # Store interface
│   └── user_test.go
├── order/
│   ├── order.go
│   ├── store.go
│   └── order_test.go
├── postgres/
│   ├── user_store.go   # PostgreSQL implementation of user.Store
│   └── order_store.go
├── api/
│   ├── handler.go      # HTTP handlers
│   └── middleware.go
└── cmd/
    └── server/
        └── main.go

Layer-based (для простых проектов)

myapp/
├── handler/
│   ├── user.go
│   └── order.go
├── service/
│   ├── user.go
│   └── order.go
├── repository/
│   ├── user.go
│   └── order.go
├── model/
│   ├── user.go
│   └── order.go
└── cmd/
    └── server/
        └── main.go

Стандартный layout проекта

project/
├── cmd/                    # Entry points (main packages)
│   ├── api-server/
│   │   └── main.go
│   └── worker/
│       └── main.go
├── internal/               # Private packages (can't be imported by other modules)
│   ├── config/
│   ├── server/
│   ├── user/
│   └── database/
├── pkg/                    # Public packages (importable by other modules)
│   ├── httpclient/
│   └── validator/
├── api/                    # OpenAPI specs, protobuf definitions
│   └── openapi.yaml
├── migrations/             # Database migrations
│   ├── 001_create_users.up.sql
│   └── 001_create_users.down.sql
├── configs/                # Configuration files
│   ├── config.yaml
│   └── config.example.yaml
├── go.mod
├── go.sum
├── Makefile
├── Dockerfile
└── README.md

Важно:

  • internal/ -- пакеты, которые не могут быть импортированы извне модуля (компилятор запрещает)
  • cmd/ -- каждая поддиректория -- отдельный main пакет
  • pkg/ -- необязательно, используйте если есть код для повторного использования другими модулями

Guard clauses (ранний возврат)

// Bad: arrow code (deep nesting)
func authorize(user *User, resource string) error {
    if user != nil {
        if user.IsActive {
            if user.HasPermission(resource) {
                if !user.IsBlocked {
                    return nil // success buried deep
                } else {
                    return ErrBlocked
                }
            } else {
                return ErrForbidden
            }
        } else {
            return ErrInactive
        }
    } else {
        return ErrNoUser
    }
}

// Good: guard clauses, flat code
func authorize(user *User, resource string) error {
    if user == nil {
        return ErrNoUser
    }
    if !user.IsActive {
        return ErrInactive
    }
    if user.IsBlocked {
        return ErrBlocked
    }
    if !user.HasPermission(resource) {
        return ErrForbidden
    }

    return nil
}

Преимущества:

  • Основной путь (happy path) всегда на минимальном уровне отступа
  • Ошибки обрабатываются сразу и не накапливаются
  • Легко добавить новое условие без реструктуризации

Named returns: когда использовать

// Good: named return for defer + error pattern
func readFile(path string) (content []byte, err error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, fmt.Errorf("opening %s: %w", path, err)
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = fmt.Errorf("closing %s: %w", path, cerr)
        }
    }()

    return io.ReadAll(f)
}

// Bad: named returns just for "documentation"
func divide(a, b float64) (result float64, err error) { // unnecessary
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// Good: unnamed returns for simple functions
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

Именованные возвращаемые значения оправданы только когда:

  • Нужно модифицировать err в defer (как в примере выше)
  • Функция возвращает несколько значений одного типа, и имена различают их
  • Функция длинная и имена помогают отслеживать контракт

Избегайте глобального состояния

// Bad: package-level mutable state
var (
    defaultTimeout = 30 * time.Second
    globalDB       *sql.DB
    logger         *slog.Logger
)

func init() {
    var err error
    globalDB, err = sql.Open("pgx", os.Getenv("DB"))
    if err != nil {
        log.Fatal(err)
    }
}

// Bad: function depends on hidden global
func GetUser(id string) (*User, error) {
    return globalDB.QueryRow("SELECT ...", id)
}

Проблемы:

  • Нельзя тестировать с другой БД
  • Порядок init() непредсказуем
  • Скрытые зависимости
// Good: explicit dependencies
type UserService struct {
    db      *sql.DB
    timeout time.Duration
    logger  *slog.Logger
}

func NewUserService(db *sql.DB, timeout time.Duration, logger *slog.Logger) *UserService {
    return &UserService{db: db, timeout: timeout, logger: logger}
}

gofmt -- не обсуждается

В Go форматирование кода не является предметом дискуссий:

# Format all files in project
gofmt -w .

# Or use go fmt (wraps gofmt)
go fmt ./...

# goimports: gofmt + automatic import management
goimports -w .

Всё Go-сообщество использует один стиль. Это устраняет споры о форматировании и делает любой Go-код мгновенно узнаваемым.

Проверь себя

Как правильно назвать тип в пакете user?

Для чего служит директория internal/ в Go-проекте?

Когда оправданы именованные возвращаемые значения (named returns)?