В поисках хорошего стиля. Часть 1. Зачем нам свои линтеры на Go в Островке
- пятница, 30 августа 2024 г. в 00:00:12
Мы написали свои линтеры для Go, которые умеют находить пустые инициализации и проверять экспортируемость полей и методов типов. Сегодня мы поговорим о том, как наша команда пришла к собственному линтеру, и немного погрузимся в детали его реализации.
Всем привет! Меня зовут Артём Блохин, я Golang-разработчик в команде интеграций Островка.
Если бы «Рождественская история» Чарльза Диккенса была про стиль кода, то получилось бы как-то так:
«Начнём сначала: код-стайл умер. Сомневаться в этом не приходилось. Свидетельство о его погребении было подписано девопсом, архитектором и тимлидом. Оно было подписано разработчиком Островка».
Сейчас у Островка более 260 интеграций с различными поставщиками отельных сервисов, и с каждым годом их становится больше. Кода много. Все интеграции мы стараемся писать в одном стиле и за последние 5 лет выработали столько правил оформления, что даже ребята с опытом иногда забывают некоторые нюансы.
Возможным решением этой проблемы могла быть статья-памятка, которая направляла бы нас в процессе написания кода, но опыт показал, что эффективность такого подхода незначительна. Часто вся тяжесть контроля качества ложится на плечи ревьюеров, которые вынуждены из merge-request’а в merge-request повторять одни и те же замечания. Я расскажу, как мы выходим из этой ситуации.
Мы используем линтеры — инструменты, которые помогают автоматически проверять код на ошибки и соответствие стандартам. Линтеры допускают гораздо меньше ошибок, чем люди.
Go известен своей лаконичностью и простотой синтаксиса. Тем не менее именно эти же качества могут невольно провоцировать разработчиков к пренебрежению хорошими практиками программирования. Упрощённый синтаксис и отсутствие некоторых концепций, присущих другим языкам, могут привести к менее структурированным и поддерживаемым, более непонятным решениям.
Рассмотрим пример: предположим, что у нас есть структура User и мы решили объявить переменную с этим типом.
user := User{}
var user User
Оба варианта приведут к одинаковому результату — созданию нового экземпляра пользователя. Однако вопрос выбора стиля остаётся открытым и во многом зависит от индивидуальных предпочтений и корпоративных правил. Что же касается нашей команды, то мы склоняемся ко второму варианту, так как он кажется нам более читаемым. На наш взгляд, чёткие обозначения типов делают код более понятным.
Хороший стиль кода — это субъективное понятие, а единообразие стиля — нет. С единообразным кодом удобнее работать, потому что мы знаем, чего от него ждать в следующем блоке, строке, условиях, функциях. Читая такой код, мы не тратим наше внимание на вопросы вроде «почему здесь присваивание, а здесь переменная?» или «здесь я должен был вернуть указатель на пустую структуру или nil?». Это важно, потому что мы пишем код не только для компьютеров, но и для других программистов.
Для нашей команды важно придерживаться одного стиля. И так получилось, что для нашего стиля не было готового линтера, поэтому мы создали свой.
Для этого мы проанализировали замечания на ревью и разработали несколько линтеров. Самыми частыми замечаниями в них были:
объявление пустого литерала через :=
;
использование new для получения указателя;
использование приватных полей и методов из чужих структур
Сейчас подробно расскажем, как наши линтеры помогают находить эти замечания.
Замечание про пустой литерал (Empty Composite Literal) встречалось на ревью чаще всего. Мы хотим, чтобы новые переменные с типом явно объявлялись с использованием var.
В первом подходе мы назвали линтер zeros
— от zero-value struct.
Но в какой-то момент мы стали замечать, что разработчики любят объявлять через :=
не только пустые структуры, но и остальные структуры данных, например, срезы. Тогда мы решили применить опыт со структурами к остальным типам данных. А так как теперь линтер не только про структуры, мы поменяли название на emptycl
от empty composite literals.
Вот как работает этот линтер.
Берём структуру:
type T struct {
F int
}
Затем внутри функции пробуем объявить пустые типы:
t3 := T{}
_ = t3
ii3 := []int{}
_ = ii3
m3 := map[int]int{}
_ = m3
t4 := &T{}
_ = t4
ii4 := &[]int{}
_ = ii4
m4 := &map[int]int{}
_ = m4
Вот так линтер покажет все места, где нужно исправить на var:
src.go:10:8: emptycl: composite literal without elements (tppcs)
t3 := T{}
^
src.go:12:9: emptycl: composite literal without elements (tppcs)
ii3 := []int{}
^
src.go:14:8: emptycl: composite literal without elements (tppcs)
m3 := map[int]int{}
^
Но и здесь есть краевые сценарии. Например, interface{}
:
type I interface{}
Мы хотим проверить, что тип удовлетворяет интерфейсу. Тогда мы должны сделать так:
_ I = T{}
…И здесь наш линтер упал. Мы так зациклились на идее с пустыми типами, что забыли о случаях, когда это не ошибка.
Это нормальная ситуация для новых линтеров. Обычно мы на время выключаем линтер или добавляем в коде //nolint
, пока не починим.
Мы решили, что лучшая среда для тестирования линтера — это наш проект. А так как над проектом работает несколько разработчиков, все странные краевые сценарии мы отлавливаем довольно быстро.
Мы не любим функцию new, потому что она инициализирует переменную с zero value, а это не явно. Про объявление переменных хорошо написал Дейв Чейни в своей статье On declaring variables, которую мы обычно отправляем вместе с комментарием про new на код-ревью.
Мы назвали линтер для замечания про new — nonew. Он запрещает использование встроенной функции new для получения указателя. Изначально nonew был отдельной проверкой в emptycl, но мы решили, что по смыслу логика всё-таки разная.
Вот как выглядит ошибка линтера nonew
:
src.go:33:7: nonew: using the new found (tppcs)
i := new(int)
^
Даже на такой, казалось бы, маленький линтер мы пишем тесты и проверяем, что он работает так, как задумано. Подробнее про то, как мы организовали тесты для своих линтеров, мы расскажем в «Примере реализации линтера».
Следующим по популярности замечанием было «почему в чужой структуре ты используешь приватные поля и методы?». Мы заметили, что разработчики довольно долго привыкают к этому правилу и повторяют ошибку ещё несколько раз, пока не набьют руку.
Проверка приватности осложняется тем, что правило должно применяться и внутри структуры, и при вызове функции new
. Нужно было как-то понять, принадлежит ли структура методу, в котором вызван приватный метод или идёт обращение к приватному полю. Возникают и другие вопросы: мы функцию вызываем или метод? Структура вложенная или на уровне пакета?
Разработка линтера превращалась в хроники из криминальных новостей: чем больше слышишь, тем страшнее становится.
Мы заново переписали линтер несколько раз и дали ему название exportrules
. Вот несколько примеров его работы.
Есть структура с двумя полями и одним приватным методом:
type foo struct {
Public int
private int
}
func (f foo) doSmt() {}
Напишем функцию, которая получает эту структуру на вход и использует её приватное поле:
func boo(f foo) {
f.private++
}
Другой сценарий — использование приватного метода:
type job struct{}
func (j job) someMethod(f foo) {
f.private++
f.Public = 10
f.doSmt()
}
Вот что скажет линтер на оба этих сценария:
src.go:11:2: exportrules: invalid access to unexported field (tppcs)
f.private++
^
src.go:17:2: exportrules: invalid access to unexported field (tppcs)
f.private++
^
src.go:19:2: exportrules: invalid access to unexported method (tppcs)
f.doSmt()
^
Мы провели эксперимент на новых разработчиках и посмотрели на их лица, когда они впервые прочитали ошибки линтера exportrules в своём коде. Реакция всегда была примерно одинаковой:
Но такова цена хорошего стиля, ничего не поделаешь…
Ради интереса давайте посмотрим на другой пример:
func justFunc() {
type T struct {
private int
}
var t T
t.private = 10
}
Как вы думаете, линтер здесь будет ругаться или нет? Напишите в комментариях.
Все 3 линтера — emptyctl
, nonew
и exportrules
— используют AST (Abstract Syntax Tree), чтобы распарсить код. Затем мы проходим по дереву и находим определённую комбинацию узлов, подходящую под правила линтера.
Например, у нас есть такой код:
package main
type Foo struct {
A int
B string
}
func justFunc() {
f := Foo{}
}
При парсинге он превратится в такое дерево:
Здесь каждому узлу соответствует определённая переменная, ключевое слово, блок и т. д.
Узел с f := Foo{}
выглядит так:
А это псевдокод проверки на наличие таких узлов в AST всей программы:
ФУНКЦИЯ(литерал, стек)
ЕСЛИ литерал не пустой ИЛИ стек маленький
ВЫЙТИ из функции
ОПРЕДЕЛИТЬ родительский узел для литерала
ЕСЛИ родительский узел — это унарное выражение (например, взятие адреса)
УСТАНОВИТЬ нового родителя с предыдущего узла в стеке
ОБРАБОТАТЬ родительский узел
ЕСЛИ родительский узел это оператор присваивания и не ':=',
или если это объявление типа интерфейса
ВЫЙТИ из функции
СООБЩИТЬ об ошибке: 'пустой литерал композитного типа'
КОНЕЦ ФУНКЦИИ
Парсинг AST — только малая часть линтера. Ещё нужно подумать над несколькими моментами:
как организовать тесты;
как добавить линтер в CI/CD;
как учесть особенности сборки плагинов для golangci-lint и многое другое.
Мы считаем, что написание своего линтера с нуля заслуживает отдельной статьи, и про это расскажем во второй части «В поисках хорошего стиля». Подписывайтесь на Телеграм-канал Ostrovok! Tech, чтобы не пропустить!
Напоследок поделимся, как мы подключаем свои линтеры к проектам.
После того как мы написали наш линтер, остаётся вопрос: а как его использовать? У нас есть два пути:
Закинуть merge-request в golangci-linter, и если там всё ок, то его сливают — вы сможете использовать его через golangci-lint run ./...
(предварительно добавив в конфиг);
Если не хочется ждать или ваша цель, чтобы линтер использовался исключительно в рамках вашего проекта, то можно собрать свой образ со склонированными golangci-linter.
Сначала мы хотели использовать docker-образ golangci-lint, но идея была отвергнута, так как golangci-lint оказался чувствительным к версиям библиотек, от которых зависит. Дело в том, что нам сложно поддерживать свои линтеры, когда мы не знаем точных версий библиотек, с которыми был собран бинарник golangci-lint.
В итоге мы решили использовать легковесный образ golang, внутри которого склонировали репозиторий golangci и наши линтеры. Затем собрали образ и закинули в корпоративный docker registry. А уже внутри нашего пайплайна запускаем golangci-lint с этим образом.
Когда мне дали задачу написать линтер, я даже не догадывался, что наша команда уйдёт так далеко и напишет не просто поиск и запрет на использование new, но и что-то более стоящее и сложное!
Впереди ещё много интересных сценариев, для которых мы напишем новые линтеры.
Увидимся во второй части статьи, где мы вместе напишем собственный линтер с нуля!