Как стать автором
Обновить
0
Delivery Club Tech
Лидер рынка FoodTech в России

Анализ синтаксиса, который всегда с тобой

Время на прочтение12 мин
Количество просмотров4.1K

Всем привет, меня зовут Денис Лимарев, я разработчик платежной системы Delivery Club. И сегодня я расскажу, как мне надоели однообразные ошибки и собственная невнимательность, и как я с этим борюсь. Недавно я написал статью о нашем линтере, где вскользь затрагивал возможность написания локальных проверок под конкретный проект. Сегодня раскрою эту тему подробнее и опишу приемы, упрощающие проверку кода мне и коллегам. А в конце статьи расскажу, как можно автоматизировать некоторые проверки ИБ из нашей недавней статьи, поделюсь дальнейшими планами по развитию и оставлю ссылку на доклад автора go-ruleguard (далее ruleguard).

Анализ синтаксиса здесь и сейчас

Во-первых, давайте ответим на вопрос «Зачем это нужно?». Как правило, в каждой компании, команде или даже проекте складываются свои требования, которые стоит учитывать, и зачастую о них знают далеко не все, особенно если это проект чужой команды. А их нужно учитывать при проверке новых изменений. Мы все люди, и рано или поздно ошибаемся, поэтому хотелось бы такие проверки отдать машине. Да и мало у кого есть желание писать одни и те же замечания из PR в PR. Отсюда возникает потребность в инструменте, с помощью которого можно было бы быстро проверить проект и сообщить о нарушении требований. Тут вам на помощь придет ruleguard. Правила для него можно написать быстро, для этого не требуется знать go/ast и алгоритмы его обхода, что упрощает создание и поддержку таких проверок. Также ruleguard поддерживают golangci-lint, go-critic, dcRules (далее линтеры), что упрощает интеграцию в CI/CD и другие этапы проверок.

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

Необходимый минимум, чтобы писать эффективные ruleguard-правила:

  1. знать Go-синтаксис;

  2. знать виды переменных ruleguard;

  3. немного хитрости;

  4. желание не писать одни и те же комментарии к PR.

Краткий обзор видов переменных ruleguard:

  1. $_ — захватываем обращение, не сохраняя имя;

  2. $arg — захватываем обращение с сохранением в переменную arg;

  3. $*_— захватываем всю область, например, параметры через запятую или кусок кода в несколько строк, не сохраняя;

  4. $*args — захватываем всю область с сохранением в переменную args.

Краткое описание методов, используемых в правилах ruleguard:

  • Match: здесь мы описываем шаблон правила, который будет искать фрагменты кода. Метод обязателен к использованию в правиле.

  • Where: после нахождения необходимых фрагментов кода метод позволяет отфильтровать ненужные участки по внутренней информации (см. следующий абзац). Метод необязателен.

  • Report: используется для вывода сообщения об ошибке. Метод необязателен только в случае использования Suggest, в остальных случаях обязателен.

  • Suggest: используется для автозамены кода (см. третий прием ниже). Метод необязателен.

  • At: ограничивает область применения Suggest и Report конкретным выражением (см. четвертый прием ниже). Метод необязателен.

Захваченные фрагменты кода: что мы о них знаем?

При поиске необходимых шаблонов кода вы, возможно, выделите какие-то фрагменты, например, переменные, аргументы, названия функций или методов, участки кода после каких-то выражений. Если искомые фрагменты были выделены в именованные переменные, то ruleguard хранит о них такую информацию:  

  1. Текстовое представление.

  2. Типы user-defined и underlying, например: type YourType int, где YourTypeuser-defined, а intunderlying.

  3. Область видимости: сейчас вы можете узнать, принадлежит ли выделенный фрагмент к глобальной области видимости пакета.

  4. Адресуемый ли это тип.

  5. Номер строки в коде.

  6. Размер структуры.

  7. И многое другое: приводим ли тип к другому типу, реализует ли он интерфейс, название файла c фрагментом кода, импортирует ли пакет в библиотеку и т.д.

Теперь, когда у нас есть вся необходимая вводная информация, мы готовы разобрать приемы написания правил. К каждому описанному ниже правилу я дам пояснения в тексте или комментариях к коду.

Прием первый: есть ли в Go точка с запятой в конце выражения?

m.Match(`time.Now();`)

Здесь захватывается вызов функции пакета time. Мы четко обозначаем тот факт, что после вызова должно идти следующее выражение, поэтому отбрасываем варианты, при которых результат функции передается в качестве аргумента. Так происходит потому, что Go при сборке кода на этапе парсинга файлов самостоятельно подставляет точку с запятой в конец выражения. Правила ruleguard (да и многих других линтеров) применяются к результатам работы Go-парсера. Таким образом, можно отбросить вложенные выражения или обозначить их последовательность.

Прием второй: как проверить только определенные методы пакета?

m.Match(`time.Now()`, `time.Since($_)`)

Здесь мы ищем вызовы пакета time, а именно Now и Since. При этом мы не обращаем внимание на передаваемые методу Since аргументы.

Прием третий: автоисправление кода

Иногда для исправления ошибки достаточно тривиального изменения кода. Например, подставить один символ или заменить вызов одной функции на другую. В таких случаях вы можете захотеть добавить автоисправление кода линтером, и ruleguard это поддерживает. Чтобы сделать автоисправление, нужно добавить в правило метод Suggest:

func sprintfConcat(m dsl.Matcher) {
	m.Match(`fmt.Sprintf("%s.%s", $x, $y)`).
   	 Where(m["x"].Type.Is(`string`) && m["y"].Type.Is(`string`)).
   	 Suggest(`$x + "." + $y`)
}

Здесь выбираются все вызовы fmt.Sprintf, в которых обе переменные являются строкой, а шаблон форматирования — это конкатенация двух строк через точку.

Примечание: для корректной работы Suggest необходимо именовать все захваченные фрагменты кода, к которым будет применено автоисправление; в нашем примере это: $x, $y.

Прием четвертый (пункт первый): что делать, если не уверен в правиле?

Если нет полной уверенности в правиле или хочется защитить его корректность от дальнейших изменений, то можно написать тест. Для этого вам понадобится написать обычный Go-тест:

import (
	  "testing"
  
    "github.com/quasilyte/go-ruleguard/analyzer"
    "golang.org/x/tools/go/analysis/analysistest"
)

func TestRules(t *testing.T) {
      testdata := analysistest.TestData()
      err := analyzer.Analyzer.Flags.Set("rules", "rules.go")
      if err != nil {
            t.Fatalf("set rules flag: %v", err)
      }
      analysistest.Run(t, testdata, analyzer.Analyzer, "./...")
}

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

Содержание правила в rules.go:

func strconv(m dsl.Matcher) {
// Здесь мы ищем все вызовы fmt.Sprint/Sprintf, которые могут быть заменены на strconv.Itoa.

   m.Match(`fmt.Sprintf("%d", $x)`, `fmt.Sprintf("%v", $x)`, `fmt.Sprint($x)`).
      Where(m["x"].Type.Is(`int`)).
      Suggest(`strconv.Itoa($x)`)
}

Тестовый код в positive.go:

func Warn() {
      var i int
      _ = fmt.Sprintf("%v", i)   // want `suggestion: strconv.Itoa(i)`

      println(fmt.Sprintf("%s", i))// want `suggestion: strconv.Itoa(i)`

}

При обнаружении подходящих фрагментов кода ruleguard выдаст предупреждение, которое будет прочитано библиотекой go/analysis. Чтобы отметить участок тестового кода, для которого необходимо вывести предупреждение, справа от искомой строки напишите служебный комментарий формата // want: "test warning", где "test warning" — текст предупреждения. Полный рабочий пример теста и тестовых данных можно посмотреть здесь.

Примечание: текст предупреждения может быть описан в косых или двойных кавычках.

Прием четвертый (пункт второй): что делать, если не уверен в автоисправлении?

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

Содержание правила в rules.go:

func rangeExprCopy(m dsl.Matcher) {
// Здесь мы ищем все массивы, у которых размер элементов больше 256 байт.
    m.Match(`for $_, $_ := range $x { $*_ }`,
   	 `for $_, $_ = range $x { $*_ }`).
   	 Where(m["x"].Type.Is("[$_]$_") && m["x"].Type.Size >= 256).
   	 At(m["x"]).
   	 Suggest(`&$x`)
}

Примечание: At ограничивает функции Suggest и Report конкретным выражением из всего выбранного фрагмента кода. В данном случае это массив в $x.

Тестовый код в positive.go:

func warn() {
    var xs [42]byte
    for _, x := range xs { // want `suggestion: &xs`
       println(x)
    }
}

Эталонный код в positive.go.golden:

func warn() {
    var xs [42]byte
    for _, x := range &xs {
       println(x)
    }
}

Примечание: эталонный код не должен содержать служебный комментарий, используемый линтером.

После этого добавьте новый тест, идентичный предыдущему, изменив только последнюю строку c analysistest.Run на analysistest.RunWithSuggestedFixes:

import (
    "testing"

    "github.com/quasilyte/go-ruleguard/analyzer"
    "golang.org/x/tools/go/analysis/analysistest"
)

func TestRules(t *testing.T) {
      testdata := analysistest.TestData()
      err := analyzer.Analyzer.Flags.Set("rules", "rules.go")
      if err != nil {
	           t.Fatalf("set rules flag: %v", err)
      }
      analysistest.RunWithSuggestedFixes(t, testdata, analyzer.Analyzer, "./...")
}

Полный рабочий пример теста и тестовых данных для автоисправления можно посмотреть здесь и здесь.

Прием пятый: захватываем все функции и свойства

Когда нам не важны аргументы или какая-то информация о функции, мы можем это отбросить с помощью $_, например так:

m.Match(`for $_, $_ := range $_ { $*_; defer $_; $*_ }`)

Здесь мы ищем случаи, когда в цикле вызывается выражение defer и нам не важно, в результате чего мы получаем данные для итерирования.

Прием шестой: мне не важны аргументы, кроме одного

Бывают ситуации, когда вам важен только один аргумент из списка переданных. Например, функция принимает тип variadic, или заранее неизвестно, какое количество аргументов будет принимать функция, или вам просто лень всё перечислять, такое тоже бывает. В таком случае вы можете отбросить все аргументы или переменные и искать только нужный:

Захватываем все переменные с типом error, которые создаются через присвоение :=:

m.Match(`$*_, $err := $_;`).
Where(m["err"].Type.Is("error")

Захватываем все аргументы с типом map, где нам не важен тип ключа и значения:

m.Match(`$_($*_, $map, $*_);`).
Where(m["map"].Type.Underlying.Is(`map[$_]$_`)

Примечание: если нам были бы необходимы хэш-таблицы (далее просто таблицы), имеющие в качестве ключа или значения только определенные типы, то выражение могло бы выглядеть иначе, например: 

  • map[string]error: будут отобраны только таблицы с данным типом;

  • map[int]$_: будут отобраны все таблицы с ключом int и любым типом в качестве значения;

  • map[$_]int: будут отобраны таблицы с любым ключом и значением с типом int.

Прием седьмой: ищем ошибки стиля именования

Так как ruleguard хранит текстовое представление фрагментов кода, то можно искать и проверять стиль именований, а также делать другие проверки стиля. Рассмотрим на примере проверки функций:

m.Match( 
    `func $x($*_) $*_ { $*_ }`, // выборка обычных функций
    `func ($_) $x($*_) $*_ { $*_ }`, // выборка методов структур
    `func ($_ $_) $x($*_) $*_ { $*_ }` // выборка методов структур с receiver
).
Where(!m["x"].Text.Matches(`^_$`) && (m["x"].Text.Matches(`-`) || 
m["x"].Text.Matches(`_`))). // находим все строки, содержащие подчеркивания или тире
Report("use camelCase naming strategy")

Прием восьмой: уменьшаем размер правил — пишем функции

Правила для ruleguard выглядят как код на Go, но им не являются. На самом деле они написаны на DSL, который интерпретируется библиотекой go-grep, поэтому они не обладают всеми возможностями Go. Тем не менее ruleguard поддерживает анонимные функции для упрощения и компактности правил. Рассмотрим на примере упрощения записи данных в интерфейс io.Writer:

func writeBytes(m dsl.Matcher) {
    isBuffer := func(v dsl.Var) bool {
       return v.Type.Is(`bytes.Buffer`) || v.Type.Is(`*bytes.Buffer`)
    }
  
     m.Match(`io.WriteString($w, $buf.String())`).
        Where(isBuffer(m["buf"])).
        Suggest(`$w.Write($buf.Bytes())`)
  
     m.Match(`io.WriteString($w, string($buf.Bytes()))`).
        Where(isBuffer(m["buf"])).
        Suggest(`$w.Write($buf.Bytes())`)
}

Здесь мы ищем все вызовы io.WriteString, в которых тип переменной $bufbytes.Buffer. Для уменьшения размера правила оборачиваем проверку типа в анонимную функцию. Также можно заметить автоисправление кода.

Прием девятый: подключаем сторонние бандлы с правилами

На данный момент есть много сторонних бандлов с правилами ruleguard, которые можно подключить в дополнение к своим (см. полезные ссылки ниже). Сделать это достаточно просто:

import (
    "local/pkg/rules"
  
    dcRules "github.com/delivery-club/delivery-club-rules"
    "github.com/quasilyte/go-ruleguard/dsl"
)

func init() {
     dsl.ImportRules("external-dc-rules", dcRules.Bundle)
// первый аргумент функции - префикс, который будет добавляться при импортировании // правил бандла, полезен, когда названия ваших правил пересекаются с названиями
// правил в стороннем бандле
     dsl.ImportRules("local-dc-rules", rules.Bundle)
}

Файл с таким содержимым  нужно выделить в отдельный пакет внутри проекта, далее абсолютный путь до файла можно использовать в линтерах. Пример проекта с использованием локальных и импортируемых бандлов.

Прием десятый: решаем проблему импортов сторонних библиотек

Описанная ниже ситуация возможна только при написании бандлов в отдельном репозитории. Ruleguard может импортировать стороннюю библиотеку, чтобы написать правила для нее. Для этого в правиле необходимо вызвать метод Import, например:

import  "github.com/quasilyte/go-ruleguard/dsl"

func queryWithoutContext(m dsl.Matcher) {
    m.Import("github.com/jmoiron/sqlx")
  
    m.Match(`$db.Queryx($*_)`).
       Report(`don't send query to external storage without context`)
}

Здесь мы ищем вызовы методов sqlx без контекста. Но если библиотеки github.com/jmoiron/sqlx в проверяемом проекте нет, то линтер упадет, так как не сможет ее подгрузить в рантайме. Для решения этой проблемы можно создать пакет с интерфейсами для сторонней библиотеки в проекте бандла, и после этого импортировать не стороннюю библиотеку, а интерфейсы для нее. Правило станет выглядеть так:

import (
     "github.com/quasilyte/go-ruleguard/dsl"
     _ "github.com/delivery-club/delivery-club-rules/pkg"
)

func queryWithoutContext(m dsl.Matcher) {
    m.Import("github.com/delivery-club/delivery-club-rules/pkg") // пример расположения внутреннего пакета

    m.Match(`$db.Queryx($*_)`)
       Where(m["db"].Object.Is("Var") && m["db"].Type.Implements(`pkg.SQLDb`)).
         Report(`don't send query to external storage without context`)
}

В последнем правиле делается безымянный Go-импорт пакета с интерфейсами для избежания проблем с Go vendoring. Затем вызывается импорт в правиле, чтобы можно было работать с пакетом.

Примечание: напомню, код правил не является кодом на Go, поэтому им нужен отдельный импорт.

В Where мы проверяем, что найденные вызовы являются методами переменной, а переменная реализует необходимый интерфейс, где pkg.SQLDb — интерфейс, который описывает методы библиотеки sqlx.

Прием одиннадцатый: ищем аргументы

m.Match(`fmt.Sprintf($*_)`).
Where(m["$$";].Node.Parent().Is(`ExprStmt`))

Здесь мы ищем все вызовы функции fmt.Sprintf, вложенные в другие вызовы выражений. $$ обозначает предшествующее выражение, а проверка на соответствие ExprStmt — факт того, что выражение принимает результат работы функции.

Прием двенадцатый: теги для правил

Правила ruleguard поддерживают разделение по тегам, что позволяет выполнять только нужные группы правил без их явного перечисления. Это полезно при работе с локальными для проекта правилами и при разработке наборов правил. С помощью тегов можно фильтровать экспериментальные, стилевые, медленные правила и так далее. Добавить теги легко, для этого перед объявлением правила необходимо описать комментарий в форме //doc:tags:, без пробела после //:

//doc:summary Detects Before call of time.Time that can be simplified.
//doc:tags	style experimental
//doc:before  !t.Before(tt)
//doc:after   t.After(tt)
func timeComparisonSimplify(m dsl.Matcher) {
    isTime := func(v dsl.Var) bool {
   	   return v.Type.Is(`time.Time`) || v.Type.Is(`*time.Time`)
    }
  
    m.Match(`!$t.Before($tt)`).
       Where(isTime(m["t"])).
       Suggest("$t.After($tt)")
}

Здесь мы ищем все вызовы Before типом time.Time, перед которыми стоит отрицание.

Для фильтрации правил по тегам при выполнении через golangci-lint необходимо в конфигурации go-critic в блоке для ruleguard явно описать теги, которые должны быть включены или выключены. Это можно сделать в соответствующих полях enable, disable c знаком # перед каждым названием тега.

Пример конфигурации .golangci.yml:

linters-settings:
  gocritic:
    settings:
      ruleguard:
          enabled: '#diagnostic,#performance'

Фильтрация тегов была добавлена недавно и на текущий момент поддерживается только последними версиями go-critic (>=v0.6.3) и мастером golangci-lint — dcRules.

Примечание: помимо тегов есть и другие служебные комментарии: summary, before и after. Основная их цель — объяснить смысл правила и суть изменений, к которым приведет его соблюдение.

Известные особенности, о которых стоит знать:

  1. Не все Go-переменные имеют тип. Это связано с особенностью использования _ — этот вид переменной не имеет типа. Поэтому когда вы проверяете переменную на принадлежность к типу или реализации интерфейса, правило может не сработать. Issue.

  2. Применение регулярных выражений к большим фрагментам кода может сильно замедлить проверку правил. Старайтесь по возможности добавлять дополнительные условия в Where перед вызовом регулярных выражений.

  3. Если вы импортируете пакет, которого нет в проекте, то линтер завершит выполнение с ошибкой. Issue.

  4. Пока что нельзя проверить все переменные, константы или типы, если они записаны в сгруппированной форме. Issue.

Заключение

На данный момент ruleguard умеет многое, и библиотека продолжает развиваться. Недавно вышла новая версия go-critic (0.6.3), которая была уже добавлена в master golangci-lint; помимо новых проверок была также добавлена улучшенная функциональность ruleguard (~v0.3.16). Надеюсь, вам пригодится.

Тем временем силами @quasilyte и при поддержке open source-сообщества (в том числе при моем участие) дальше прорабатывается возможность написания более гибких правил, а именно:

  1. Интерпретатор quasigo для возможности написания более широкого спектра условий.

  2. Добавление локальных интерфейсов, чтобы уйти от необходимости писать отдельный пакет под интерфейсы, как это сделано в шестом приеме. Issue.

  3. Обработка отсутствующих импортов в правилах для уменьшения случаев завершения выполнения с ошибкой. Issue.

  4. Поддержка SSA для возможности написания более широкого спектра правил. Issue.

  5. Добавление возможности блокировать конкретное правило, а не только весь линтер в golangci-lint. Issue.

Планы по развитию линтеров, в том числе из прошлой статьи, которые были реализованы к текущему дню:

  1. Добавление возможности быстрого исправления кода через ruleguard: реализовано в golangci-lint 1.44.0 (PR).

  2. Добавление возможности игнорирования конкретных ruleguard-правил в конфиге: реализовано в go-critic 0.6.2 (PR) и golangci-lint 1.44.0.

  3. Добавлена возможность игнорировать правила по тегам ruleguard: реализовано в go-critic 0.6.3 (PR) и в мастере golangci-lint.

  4. Помощь в тестировании и реализации submatch-выражений ruleguard (метод Contains): добавлено в go-critic 0.6.3 (PR), в golangci-lint обновление в мастере, ждем следующего релиза (>1.45.2).

  5. Добавлена возможности компилировать линтер: реализовано в dcRules 0.7.0 (PR).

  6. В ruleguard добавлена возможность проверять область видимости переменной (PR).

  7. А также несколько оптимизаций рантайма go-critic (1, 2, 3).

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

Полезные ссылки

  1. Реализация некоторых проверок ИБ из статьи коллег.

  2. Доклад Искандера об интерпретаторе quasigo для ruleguard.

  3. Бандл правил от DC.

  4. Бандл правил по стандартам Uber

  5. Набор правил ruleguard в go-critic без бандла.

  6. Набор правил от разработчика TinyGo без бандла.

Послесловие

Смотрите запись митапа в VK Tech Talks за 14 апреля: Искандер (он же @quasilyte) выступил с докладом про Go-интерпретатор quasigo в ruleguard: если коротко, то интерпретатор позволит нам еще лучше оградить себя от случайных ошибок. Как небольшой спойлер, скажу, что сейчас на бенчмарках интерпретатор обгоняет такие популярные решения на Go, как yaegi и scriggo.

Теги:
Хабы:
Всего голосов 24: ↑23 и ↓1+22
Комментарии7

Публикации

Информация

Сайт
tech.delivery-club.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Yulia Kovaleva

Истории