CI, кодстайл и TDD: обзор практик для повышения качества кода

3699e63e5d0df1861ab598690ea66c27.jpeg

Я видел не во сне, а наяву атакующие корабли, пылающие под четырьмя вложенными if-else, и лучи CI с кучей сканирований у ворот Тангейзера, вызывающие лютую боль разработчиков. Меня зовут Максим, и я техлид в Газпромбанке.

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

Моё понимание чистого кода: это исходники, каждый файл которых, например, связан с одной зоной ответственности — доменом; методы и классы в нём лаконичны, не расползаются на пять вложенных циклов с ветвлениями. Качественный же код вбирает в себя свойства чистого кода, дополняет их вниманием к техническому исполнению и тестируемостью классов по отдельности.

В примерах будет код на Kotlin, однако это не означает, что практики нельзя применить к другим языкам. Руководство подойдёт даже для YAML-программистов. Попробуйте после прочтения материала посмотреть на какой-нибудь код ролей Ansible, и наверняка обнаружите простор для улучшения.

Код для людей

  1. Пишите код и тесты так, чтобы они были главной документацией разработчика

Ваш код должен выглядеть и читаться как основная документация. Это не значит, что аналитическую и бизнес-документацию надо выбросить в мусорку. Такие материалы должны дополнять тесты и кодовую документацию как спецификация для сверки.

Как достичь такого «самодокументированного» кода? Начните с постепенного введения Type-Driven Development и тестов бизнес-логики. В случае с Kotlin: делайте sealed class и в нём описывайте все возможные результаты выполнения метода через классы и object«ы.

Вот пример:

data class SubscriberDataUpdate private constructor(
   private val dataUpdate: DataUpdate,
   private val subscriber: Subscriber
) {
 
   val subscriberId: SubscriberId = subscriber.subscriberId
   val dataUpdateId: DataUpdateId = dataUpdate.dataUpdateId
 
   fun prepareUpdateRequest(): Result =
       when (isUpdateRequired()) {
           true -> createSubscriberUpdateRequest()
           else -> failNoUpdateRequired()
       }
 
   private fun failNoUpdateRequired(): Result =
       Result.failure(RuntimeException("No Update Required"))
 
   private fun createSubscriberUpdateRequest()
           : Result =
       Result.success(
           SubscriberUpdateRequest(
               subscriberId.value,
               dataUpdate.msisdn.value,
               dataUpdate.mobileRegionId.value,
               this
           )
       )
 
   private fun isUpdateRequired(): Boolean =
       subscriber.mobileRegionId != dataUpdate.mobileRegionId

    companion object {
       fun emerge(
           dataUpdate: DataUpdate,
           subscriberResult: Result
       ): Result =
           subscriberResult.map {
               SubscriberDataUpdate(dataUpdate, it)
           }
   }
 
}

Type Driven в лучах ValueObject

data class SubscriberId
private constructor(
   override val value: String
) : ValueObject {
   companion object {
       fun emerge(subscriberId: String)
               : Result =
           when (isStringConsists9Digits(subscriberId)) {
               true -> Result.success(SubscriberId(subscriberId))
               else -> Result.failure(IllegalArgumentException("Subscriber Id consists of numbers maximum length 9"))
           }

       private val isStringConsist9Digits = "^\\d{1,6}\$".toRegex()
       private fun isStringConsists9Digits(value: String) =
           isStringConsist9Digits.matches(value)
   }
}

TESTS

Суть в этом — класс сам себя тестирует:

nternal class SubscriberIdTest {
   @Test
   fun success() {
       val sut = SubscriberId.emerge("888")
       assertThat(sut.isSuccess).isTrue
       assertThat(sut.getOrThrow().value).isEqualTo("888")
   }

   @Test
   fun failWithMoreThen9Digits() {
       val sut = SubscriberId.emerge("1234567890")
       assertThat(sut.isFailure).isTrue
       assertThat(sut.exceptionOrNull()!!.message).isEqualTo("Subscriber Id consists of numbers maximum length 9")
   }

   @Test
   fun failWithWrongFormat() {
       val sut = SubscriberId.emerge("L124S")
       assertThat(sut.isFailure).isTrue
       assertThat(sut.exceptionOrNull()!!.message).isEqualTo("Subscriber Id consists of numbers maximum length 9")
   }

   @Test
   fun failWithWrongFormat2() {
       val sut = SubscriberId.emerge("11aa")
       assertThat(sut.isFailure).isTrue
       assertThat(sut.exceptionOrNull()!!.message).isEqualTo("Subscriber Id consists of numbers maximum length 9")
   }

   @Test
   fun failWithEmpty() {
       val sut = SubscriberId.emerge("")
       assertThat(sut.isFailure).isTrue
       assertThat(sut.exceptionOrNull()!!.message).isEqualTo("Subscriber Id consists of numbers maximum length 9")
   }

}

Нюансы

  • Внедрение Type-Driven Development сопряжено с пусть и небольшими, но затратами.

  • Требует перемен в складе мышления.

  • Не совсем понятно, что в Type-Driven Development делать с исключениями. Но мы к этому ещё вернёмся.

  1. Логи и мониторинг в помощь инженерам

Что лучше — писать логи в какой-то файл где-то на файловой системе или отправлять их в stdout? Отправлять логи строками в Elasticsearch или потратить время на изучение distributed tracing и структурированных логов? Спроектированный с умом мониторинг сокращает время обратной связи и упрощает поиск причины неполадок там, где нет дебаггера под рукой.

Как можно внедрить логи с метриками на примере незамысловатого класса-прослойки для чтения файла. Подключить сбор метрики: alphaMetrica (productId, route).

В коде это будет выглядеть так:

fun dataUpdateProcess(unvalidatedUpdateRequest: UnvalidatedDataUpdateRequest)
       : Mono> =
   Mono.just(ValidatedDataUpdateRequest.emerge(unvalidatedUpdateRequest))
       .flatMap { findDataWithUpdates(it) }
       .flatMap { findSubscriberForUpdate(it) }
       .flatMap { prepareSubscriberUpdateRequest(it) }
       .flatMap { updateSubscriber(it) }
       .flatMap { sendMetrica(it) }

Всё должно быть прозрачно, очевидно и настраиваемо.

Особенности:

  • Делать логи надо такими же читаемыми, как и код, т. е. избегать println (»123»).

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

  • Или включить в label-метрики URL запроса и таким образом размножить их в Prometheus. В любом из двух случаев вы получите по шапке от соседних команд или руководства.

  1. DSL в бизнес-логике: нееш подумой

Доменно-специфичные языки появились сравнительно давно, один из первых популяризаторов DSL — это язык Groovy. С помощью DSL-конструкций можно элегантно и декларативно решать разные задачи, но есть и подводные камни в виде оверхеда лямбд или сложностей проектирования. И да, если вы пытаетесь лечить бойлерплейт в бизнес-логике приложения с помощью DSL или иных декларативных решений, например data-классами в Python (которые с type hints), дважды подумайте. 

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

  1. Синтаксические возможности языка и их польза для code quality

Как использовать фичи ЯП и не нарываться на ошибки или code smell? Говоря опять про Kotlin, функции-расширения — это очень здорово, мы в Газпромбанке ими активно пользуемся. И идея с композицией вместо наследования в data-классах мне нравится — всё как Джошуа Блох советует.

Но у каждого языка есть и свои сложные синтаксические решения. Inline-методы с noinline- и crossinline-аргументами тому пример. Или ООП в Python, свойства и геттеры-сеттеры. Как и с DSL, если дело дошло до синтаксических приёмов в бизнес-логике, то остановитесь и задумайтесь, как вы оказались в таком положении.

Как можно делать:

fun  Result.Companion.zip(a: Result, b: Result, c: Result)
       : Result> =
   if (sequenceOf(a, b, c).none { it.isFailure })
       Result.success(Tuples.of(a.getOrThrow(), b.getOrThrow(), c.getOrThrow()))
   else
       Result.failure(sequenceOf(a, b, c).first { it.isFailure }.exceptionOrNull()!!)

Возможные проблемы:

  1. ето object. А ето кто?

Речь идёт о null, None, nil, в общем, о нулевых значениях. В принципе, здесь всё просто: не надо, просто не надо писать код с null. Мы решительно не используем null. Только Either, только хардкор от мира ФП! Или sealed classes (проще говоря, классы с приватным конструктором), в конце концов. Но ещё лучше инкапсулировать данные в классы с бизнес-логикой.

Если вам трудно перейти к полному отсутствию null, начните с полумер, другими словами, внедрите null-safety, Optional«ы и null-checking через »?.». Всяко лучше, чем ничего!

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

  1. Гарри Паттерн и кубок кода

Шаблоны проектирования полезны. Мы используем различные паттерны в процессах дизайна кода. Вот лишь часть: ФП, монады, railway-oriented programming (когда код последователен и не перескакивает из места на место). А также мы отказались от исключений. Как? Да просто — через те же sealed classes и Either для описания ошибок как обычных доменных объектов! А не каких-то исключений, которые обрабатываются где-то далеко по стеку.

Вот пример кода с бизнес-логикой, которую можно расширять. И код легко читать.

Смотри какое элегантное решение:

fun dataUpdateProcess(unvalidatedUpdateRequest: UnvalidatedDataUpdateRequest)
       : Mono> =
   Mono.just(ValidatedDataUpdateRequest.emerge(unvalidatedUpdateRequest))
       .flatMap { findDataWithUpdates(it) }
       .flatMap { findSubscriberForUpdate(it) }
       .flatMap { prepareSubscriberUpdateRequest(it) }
       .flatMap { updateSubscriber(it) }

Плюс этого решения с TDD + DDD в том, что он надёжен и прост. Мне, как лиду, легко его контролировать, а разработчику остаётся просто бежать по этому заботливому коридору к ЧистоКоду в конце тоннеля с электроовцами.

Сложности:

  • Трудно перестроить свои мозги под мышление паттернами, думать тем же ФП и монадами.

  • Перевести всю команду на новую «поваренную книгу» — думал будет труднее, но очень много позитивных отзывов. Опытные разработчики повидавшие еще Великих Гигантов отмечают эту историю как элегантной кодописью.

  1. Конфиги мои конфиги

Мы в каком-то смысле поклоняемся постулатам The Twelve-Factor App. 

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

Другой: не использовать группы настроек, поскольку так можно получить комбинаторный взрыв из сочетаний, — про логику слияния и переопределения параметров я вообще молчу. Поэтому не усложняйте себе жизнь — используйте самое простое решение, environment variables!

Суть: вся конфигурация контурозависимая, выносится и просовывается через переменные окружения. Пример конфига интеграции:

rest:
 services:
   subscribers:
     url: http://localhost:8080 #wireMock me local
     updates: /api/v1/subscribers/updates/{dataUpdateId}
     subscribers: /api/v1/subscribers/{subscriberId}

И в тестах:

rest:
 services:
   subscribers:
     url: http://localhost:${wiremock.server.port} #wireMock me local

wiremock:
 server:
   port: 4567

Всё, что может помешать внедрению такого подхода, это технические ограничения фреймворка или рантайма.

  1. Выбирайте фреймворки правильно. А что там, на заборе, написано?

При открытии какого-то перечня веб-фреймворков из абстрактного awesome-list вам может показаться — вот оно, в этом фреймворке решены все проблемы человечества!  

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

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

Код для машин

  1. Делайте тесты. А как ка… тестировать?

Наша команда буквально живёт идеей TDD: мы начинаем код с юнит-тестов, затем пишем интеграционные BDD-тесты бизнес-логики и добавляем иногда щепотку end-to-end-тестов — собственно, всё согласно пирамиде тестирования. Не рожку мороженого — пирамиде!

Писать с TDD проще, чем кажется: просто попробуйте писать сначала гипотетические тесты перед кодом. Если не получается — подумайте, каким кодом вы бы хотели пользоваться.

  1. CI/CD в помощь команде

Конвейеры автосборки — это просто чудо. С ними можно легко задеплоить сервис на тестовую среду, выполнить тесты и провести проверки кода через SAST или линтинг. Конечно, это не так легко — например, взять те же тесты: среди них могут быть плавающие, флапающие тест-кейсы. 

Как бороться: запускайте в CI только небольшой, самый стабильный набор тестов и постепенно расширяйте его по мере развития пайплайна. С SAST надо быть осторожным, поскольку при слишком агрессивных проверках он может не помогать команде, а работать во вред процессам.

Если в целом, то советую начинать CI/CD с автотестов и потом уже внедрять линтеры, анализ кода и анализ зависимостей.

  1. Преждевременная оптимизация

Не налегайте слишком на оптимизации в сервисе, ведь вы можете навредить качеству кода, причём сильно. Могу предложить делать оптимизации красиво — так, чтобы это соответствовало гайдлайнам и best practices фреймворка, но для этого нужен навык. При этом некоторые оптимизации можно сделать, даже немного приукрасив кодовую базу: например, паттерны пагинации, batch processing или потоковой обработки могут и оптимизировать код, и сделать его в чём-то понятнее.

Вместо заключения

Это всё наша команда извлекла из своих уроков жизни и программирования. А какие приёмы и советы знаете вы? Приглашаем в комментарии!

© Habrahabr.ru