Недавно меня попросили подробно рассказать, что делает код в Golang хорошим или плохим. Я нашел это упражнение очень интересным. Собственно, достаточно интересно, чтобы написать об этом пост. Чтобы проиллюстрировать свой ответ, я рассмотрел конкретные примеры использования, с которыми я столкнулся в области управления воздушным движением (ATM). Проект доступен в Github.

Контекст

Сначала несколько слов, чтобы объяснить контекст реализации.

Евроконтроль - это организация, управляющая воздушным движением по странам Европы. Общая сеть для обмена данными между Евроконтролем и поставщиком аэронавигационного обслуживания (ANSP) называется AFTN. Эта сеть в основном используется для обмена сообщениями двух разных типов: сообщениями ADEXP и ICAO. Каждый тип сообщения имеет свой собственный синтаксис, но с точки зрения семантики оба типа эквивалентны (более или менее). Учитывая контекст, производительность должна быть ключевым элементом реализации.

Этот проект должен предоставить две реализации для синтаксического анализа сообщений ADEXP (ИКАО не управляется в рамках этого упражнения) на основе Go:

  • Плохая реализация (название пакета: bad)
  • Реорганизованная реализация (название пакета: хорошо)

Пример сообщения ADEXP можно найти здесь.

В рамках этого упражнения парсеры обрабатывают только подмножество полей, которые мы можем найти в сообщении ADEXP. Тем не менее, по-прежнему актуально проиллюстрировать распространенные ошибки Go.

Парсинг

Вкратце, сообщение ADEXP - это набор токенов. Тип токена может быть любым:

-ARCID ACA878

Это означает, что ARCID (идентификатор самолета) - ACA878.

-EETFIR EHAA 0853
-EETFIR EBBU 0908

Этот пример представляет собой список FIR (область полетной информации). Первый FIR - это EHAA 0853, а второй - EBBU 0908.

-GEO -GEOID GEO01 -LATTD 490000N -LONGTD 0500000W
-GEO -GEOID GEO02 -LATTD 500000N -LONGTD 0400000W

Повторяющийся список токенов. Каждая строка содержит подсписок токенов (в этом примере GEOID, LATTD, LONGTD).

Учитывая контекст, важно реализовать версию, использующую распараллеливание. Итак, алгоритм следующий:

  • Этап предварительной обработки для очистки и перестановки входного сообщения (мы должны очистить потенциальные пробелы, переставить токены, которые являются многострочными, например, КОММЕНТАРИЙ и т. Д.)
  • Затем разбиваем каждую строку в данной горутине. Каждая горутина будет отвечать за обработку одной строки и возвращение результата.
  • И последнее, но не менее важное: сбор результатов и возврат структуры сообщения. Эта структура является общей, независимо от типа сообщения (ADEXP или ICAO).

Каждый пакет содержит файл adexp.go, раскрывающий основную функцию ParseAdexpMessage ().

Пошаговое сравнение

Давайте теперь посмотрим, что я считаю плохим кодом и как я его реорганизовал.

Строка против [] байта

Плохая реализация обрабатывает только строковые вводы. Поскольку Go предлагает сильную поддержку операций с байтами (базовые операции, такие как обрезка, регулярное выражение и т. Д.) И что ввод, скорее всего, будет байтом [] (учитывая, что сообщения AFTN принимаются через TCP), на самом деле нет веских причин для принудительного использования строки Вход.

Управление ошибками

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

Хорошая реализация имеет дело с каждой потенциальной ошибкой:

Мы также можем найти некоторые ошибки в плохой реализации, например, в следующем коде:

Первая ошибка - синтаксическая. Строка ошибки не должна начинаться с заглавной буквы или заканчиваться знаками препинания в соответствии со стандартами Go.
Вторая ошибка связана с тем, что если строка ошибки является простой константой (форматирование не требуется), это вызов ошибок. New () немного более производительный.

Хорошая реализация выглядит так:

Избегайте гнездования

Функция mapLine () является хорошим примером вызовов вложенности, которых можно избежать. Плохая реализация:

Напротив, хорошая реализация - это плоское представление:

На мой взгляд, это упрощает чтение кода. Кроме того, это плоское представление также необходимо применять для управления ошибками. В качестве примера:

Следует заменить на:

И снова вторая версия кода читается легче.

Передача данных по ссылке или по значению

Сигнатура функции предварительной обработки в плохой реализации:

Учитывая контекст этого проекта (производительность имеет значение) и учитывая, что сообщение потенциально может быть довольно тяжелым, лучшим вариантом было вместо этого передать указатель на структуру контейнера. В противном случае в предыдущем примере значение контейнера будет копироваться при каждом вызове.

Хорошая реализация не сталкивается с этой проблемой, поскольку имеет дело со срезами (простая 24-байтовая структура независимо от базовых данных):

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

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

Распараллеливание

Плохая реализация основана на хорошей исходной идее: использование горутин для распараллеливания обработки данных (одна горутина на строку).

В плохой реализации это достигается путем перебора количества строк и создания вызова mapLine () в горутине.

Функция mapLine () принимает в качестве аргументов три параметра:

  • Указатель на окончательную структуру сообщения, которая будет возвращена. Это означает, что каждая mapLine () будет дополнять одну и ту же переменную.
  • Текущая строка
  • Канал, используемый для отправки уведомления после завершения обработки строки.

Отправка указателя на общую переменную сообщения нарушает один из основных принципов Go:

Не общайтесь, разделяя память, делитесь памятью, общаясь.

Передача этой общей переменной имеет два основных недостатка:

  • Недостаток №1: одновременные модификации фрагментов.

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

Например, структура сообщения содержит Estdata [] estdata.
Изменение среза путем добавления других estdata должно выполняться следующим образом:

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

  • Недостаток 2: ложный обмен

Совместное использование памяти между потоками / горутинами не является хорошей идеей из-за потенциального ложного совместного использования (строка кэша в данном кэше ядра ЦП может быть недействительна другим кешем ядра ЦП). Это означает, что мы должны по возможности избегать совместного использования одной и той же переменной между потоками / горутинами, если они намереваются изменить ее.

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

Давайте теперь посмотрим, как хорошая реализация справляется с распараллеливанием:

Теперь mapLine () принимает только два входа:

  • Текущая строка
  • Канал. На этот раз этот канал используется не для простой отправки уведомления о завершении обработки строки, а для отправки фактического результата. Это означает, что горутины не могут изменять окончательную структуру сообщения.

Сбор результатов выполняется таким образом родительской горутиной (той, которая порождает вызовы mapLine () в отдельных горутинах):

На мой взгляд, эта реализация более согласована с принципами Go: разделять память только посредством общения. Переменная сообщения модифицируется одной горутиной, чтобы предотвратить возможное одновременное изменение срезов и ложное совместное использование.

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

Изменить: Это предположение (одна строка = одна горутина) определенно не было хорошей идеей, поскольку приводит к слишком большому количеству переключений контекста. Для получения дополнительной информации, пожалуйста, взгляните на ссылку в главе для дальнейшего чтения (в конце сообщения).

Уведомление об обработке линии

В плохой реализации, как описано выше, как только обработка строки достигается с помощью mapLine (), мы должны указать ее родительской горутине. Это делается с использованием строкового канала chan и вызова с использованием:

Поскольку родительский элемент на самом деле не проверяет значение, отправленное каналом, лучшим вариантом было бы использовать chan struct {} с ch ‹- struct {} {} или даже лучше (с точки зрения GC) использовать интерфейс chan {} с ch ‹- ноль.

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

If

Оператор Go if позволяет передавать оператор перед условием.

Улучшенная версия:

Возможна следующая реализация:

Немного улучшает читаемость кода.

Выключатель

Еще одна ошибка плохой реализации - забыть регистр по умолчанию в следующем переключателе:

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

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

Рекурсия

ParseComplexLines () - это функция для анализа сложного токена. Алгоритм в плохом коде выполнен с использованием рекурсии:

Тем не менее, Go не поддерживает исключение хвостовых вызовов для оптимизации вызовов подфункций. Хороший код дает точно такой же результат, но с использованием итеративного алгоритма:

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

Управление константами

Чтобы разделить сообщения ADEXP и ИКАО, мы должны поддерживать постоянное значение. Плохой код делает это так:

В то время как хороший код - это более элегантное решение, основанное на Go (элегантном) iota:

Он дает точно такой же результат, но снижает количество возможных ошибок разработчика.

Функции приемника

Каждый синтаксический анализатор предоставляет функцию для определения, относится ли сообщение к верхнему уровню (по крайней мере, одна точка маршрута выше уровня 350).

Плохой код реализует это так:

Это означает, что мы должны передать сообщение в качестве входных данных функции.
В то время как хороший код - это просто функция с получателем сообщения:

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

Это также может быть первым шагом к использованию интерфейсов Go. Например, если когда-нибудь нам понадобится создать другую структуру с таким же поведением (IsUpperLevel ()), исходный код даже не нужно подвергать рефакторингу (поскольку Message уже реализует это поведение).

Комментарии

Это довольно очевидно, но плохой комментарий плохо прокомментирован.

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

В качестве примера:

Один конкретный пример в дополнение к комментарию функции также может быть очень полезным:

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

И последнее, но не менее важное: в соответствии с передовой практикой Go комментируется и сам пакет.

логирование

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

идти вперед

Go предоставляет набор мощных инструментов, таких как go fmt. К сожалению, мы забыли применить его к плохому коду, тогда как это было сделано с хорошим кодом.

DDD

DDD предлагает концепцию повсеместного использования языка, чтобы подчеркнуть важность общего языка для всех участников проекта (бизнес-экспертов, разработчиков, тестировщиков и т. Д.).
В данном примере это невозможно измерить, но сохраняя простую структуру Например, сообщение, совместимое с языком, на котором говорят внутри ограниченного контекста, также является хорошим моментом для общей ремонтопригодности проекта.

Результаты производительности

На i7–7700 4x 3,60 ГГц я провел тестовый тест, чтобы сравнить оба парсера:

  • Плохая реализация: 60430 нс / оп
  • Хорошая реализация: 45996 нс / оп

Плохой код более чем на 30% медленнее хорошего.

Заключение

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

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

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

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

Хороший разработчик - это тот, кто может найти правильный баланс между этими характеристиками в соответствии с данным контекстом.

Как и в DDD, контекст является ключевым :)

дальнейшее чтение

Рефакторинг кода Go: 23-кратная охота за производительностью