Golang — архитектурный линтер

Для того чтобы повысить качество приложения, написанного на языке go, можно использовать разные линтеры. Один из таких линтеров — архитектурный.

В приложении архитектура — это то, как код разложен по «слоям», и какие слои могут вызывать друг друга.

В данной статье расскажу про свой бесплатный, open-source, линтер с MIT лицензией и чем он может быть полезен.

debe6f88ae42176926cb952ee4bf3590.png

TLDR — Getting started

Линтер работает с любым кодом, не только с web-приложениями, но для примера посмотрим на web-приложение с одним АПИ методом:

GET /books?author=Joe

У приложения могут быть такие слои:

  • handler (тут обработчики API занимаются валидацией и трансформацией in/out данных)

  • service (бизнес-логика сервиса)

  • repository (инфровый слой для доступа к данным)

  • models (тут будут лежать domain DTO’шки)

Мы хотим, чтобы запросы в repository мог отправлять только service слой, но не мог handler, получается такая картина:

f1799c405fffccd3d132c3910040fef3.png

Такую схему зависимостей можно описать в виде yml файлика:

version: 3
workdir: internal

components:
  handler:    { in: handlers/* }
  service:    { in: services/* }
  repository: { in: repositories/* }
  models:     { in: models/** }

commonComponents:
  - models

deps:
  handler:
    mayDependOn:
      - service

  service:
    mayDependOn:
      - repository

p.s. к обозначению полей в этом файле вернемся чуть позже.

Для наглядности и простоты приложение будет выполнять 1 запрос прямо в main.go и завершаться. Тут же будет сборка зависимостей.

func main() {
  repository := booksRepository.NewRepository()
  service := booksService.NewService(repository)
  handler := booksHandler.NewHandler(service)

  books, err := handler.Books("Joe")
  if err != nil {
    panic(err)
  }

  for _, book := range books {
    fmt.Printf("Book %d has author %s\n", book.ID, book.Author)
  }

  os.Exit(0)
}

Сейчас приложение полностью соответствует описанной архитектуре, если запустить линтер и посмотреть результаты, получим ожидаемое «OK»:

$ go-arch-lint check
OK - No warnings found

Теперь можно поменять код так, чтобы он нарушал правила:

func main() {
  // ..
  repository := booksRepository.NewRepository()
  handler := booksHandler.NewHandler(
    service,
    repository, // нарушаем правило тут!
  )
  // ..
}

И запустим ещё раз:

51d9434b7a0d8a95832b0dd19bb3c8fc.png

Тут мы видим проблемный код, где конкретно и что было нарушено, и можем это поправить.

Как добавить линтер в уже существующий проект

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

Поэтому добавление линтера в проект должно происходить в несколько шагов:

2340218b7050de813a8ee39f0174cf58.png

  1. Текущее состояние проекта

  2. Добавление .go-arch-lint.yml файл с описанием идеальной архитектуры

  3. Линтер находит проблемные места в проекте. Их не стоит исправлять сразу, а можно «легализовать» добавлением в конфиг и todo комментарием (можно с ссылкой на задачу в jira или каком-то трекере)

  4. Асинхронно, в свободное время, тех. долг и т.п. можно спокойно исправить код

  5. После исправлений осталось только подчистить граф зависимостей

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

Проверка вендорных зависимостей

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

c3af0a32897f5b2439a66d59a4ff3e6a.png

Для начала нужно расширить конфиг:

  • переключить allow.depOnAnyVendor в false — теперь линтер будет проверять вендорные зависимости

  • добавить поле vendors с описанием вендорных зависимостей

  • в поле commonVendors можно указать вендорные либы, которых можно будет импортировать из любого места в проекте

  • в графе зависимостей поле canUse отвечает за список вендорных зависимостей, которые этот компонент может использовать (импортировать)

version: 3
allow:
  depOnAnyVendor: false

..

vendors:
  company:           { in: my-company.example.com/*/pkg/** }
  validation:        { in: github.com/go-ozzo/ozzo-validation }
  observability:
    in:
      - github.com/prometheus/**
      - github.com/uber-go/zap
      - go.opentelemetry.io/otel
      - go.opentelemetry.io/otel/*

commonVendors:
  - company
  - observability

deps:
  handler:
    mayDependOn:
      - service
    canUse:
      - validation

Как работает линтер

Архитектурный линтер состоит из 3 частей:

Component — это абстракция над package (пакетом). Один компонент включает в себя один или более пакетов.

Dependency — зависимость. Они бывают двух видов: явные и неявные.

  • Явные зависимости — это import в файле с описанием конкретной зависимости от другого пакета.

  • Неявные — это передача методов или структур с методами через интерфейсы, каналы и прочие способы

Dependency tree — граф отношений между компонентами (кому и от кого можно зависеть)

Для примера более сложная архитектура с такими слоями:

components:
  handler:    { in: handlers/* }
  service:    { in: services/** }
  repository: { in: services/*/repository }
  models:
    in: 
     - services/*/domain
     - services/*/dto

Если запустить маппинг, можем увидеть связь между компонентами и go пакетами:

$ go-arch-lint mapping
Package                         | Compoenent
----------------------------------------------
internal                        | -
  handlers                      | -
    articles                    | handler
    auth                        | handler
    books                       | handler
    info                        | handler
    user                        | handler
  services                      | -
    auth                        | -
      logic                     | service
      dto                       | models
      repository                | repository
    books                       | -
      somecode                  | service
      domain                    | models
      repository                | repository
    user                        | -
      admin                     | service
      groups                    | service
      dto                       | models
      repository                | repository

При разметке несколько wildcard масок могут покрыть один и тот же package, в данном примере internal/services/auth/repository/example покрывают 2 компонента:

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

В итоге линтер работает таким образом

f7dea614c7c647d0a4b462896b8cf375.png

  • размечает весь код на компоненты

  • находит все зависимости между компонентами

  • строит граф зависимостей

  • сравнивает актуальный и желаемый граф зависимостей

  • если мы получили непустой DIFF — значит, есть проблемы

Установка и запуск

Установка:

go install github.com/fe3dback/go-arch-lint@latest

Запуск:

cd code/acme/my-project
go-arch-lint check

Другие способы запуска и установки можно найти на странице проекта: https://github.com/fe3dback/go-arch-lint

Плагин для jetbrains IDE / goland

Для более удобной работы с конфигом можно установить плагин: https://plugins.jetbrains.com/plugin/15423-goarchlint-file-support

6973ad50003cfaac2e9c6d39355d67fd.png

Интеграция с другими тулзами

Все команды линтера можно запускать с флагом `--json`. Это позволит просто получить любые данные по проблемам, проекту, схеме и т.п. что удобно для интеграции с другими тулзами, CI/CD утилитами и т.п.

go-arch-lint check --json
{
  "Type": "models.Check",
  "Payload": {
    // ..
    "ArchWarningsDeepScan": [
      {
        "Gate": {
          "ComponentName": "handler",
          "MethodName": "NewHandler",
          "Definition": { .. }
        },
        "Dependency": {
          "ComponentName": "repository",
          "Name": "books.Repository",
          "InjectionAST": "repository",
          "Injection": {
            "Valid": true,
            "File": "/go/src/acme/my-project/main.go",
            "Line": 15,
            "Offset": 37
          }
        },
        "Target": { .. }
      }
    ],
    // ..
  }
}

Экспорт графа в виде картинки

Можно экспортировать граф зависимостей в виде svg файлика или d2 описания.

go-arch-lint graph
go-arch-lint graph --d2
handler -> service
service -> repository

d00f4d494d7fd70f35aa2ba0ff88fcf5.png

Плагины к другим IDE/редакторам

Если у кого-нибудь есть желание написать плагин для других редакторов (к примеру vscode), в линтере есть доп. методы для удобства интеграций.

# Основная информация о проекте
go-arch-lint self-inspect --json

# Json-schema для валидации yaml конфига
go-arch-lint schema --version 3
# {"$schema":"http://json-schema.org/draft-07/schema#", ...

# Проверка кода проекта, результаты в json
go-arch-lint check --json

# Маппинг go пакетов на компоненты
go-arch-lint mapping --json

Полный список команд и флагов можно посмотреть в --help

go-arch-lint --help            

Usage:
  go-arch-lint [command]

Available Commands:
  check        check project architecture by yaml file
  completion   Generate the autocompletion script for the specified shell
  graph        output dependencies graph as svg file
  help         Help about any command
  mapping      mapping table between files and components
  schema       json schema for arch file inspection
  self-inspect will validate arch config and arch setup
  version      Print go arch linter version

Flags:
  -h, --help                   help for go-arch-lint
      --json                   (alias for --output-type=json)

Contributing

Если есть желание помочь с разработкой, можно посмотреть на код в репозитории.

© Habrahabr.ru