Как мы создали высокодоступную распределенную систему оптимизации мобильности на Scala, используя кластеры Akka, Kubernetes и Kafka, с предметно-ориентированным подходом, мышлением функционального программирования и культурой превосходства

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

И все же на практике это то, что мы пытаемся сделать. Мы моделируем транспортные средства, бронирования, планы, маршруты, наличие, ресурсы и т. Д. В целом. Мы стремимся поддерживать большинство видов наземного транспорта, от управляемого человеком до автономного, от расписания до перевозки по требованию, от обработки пассажиров до товаров и всего такого смешанного и согласованного. Мы используем «Эверест» в качестве кодового названия нашей платформы. Это самая высокая вершина на планете, и подняться на нее сложно.

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

Я не смогу раскрыть здесь все детали нашей архитектуры. Но я хотел поделиться некоторыми руководящими принципами, которые позволили нам перейти к производству с облаком оркестровки парка. То, что мы достигли, является результатом преднамеренного дизайна. Мы строим постепенно, руководствуясь всеобъемлющим видением. Мы придерживаемся подхода, основанного на предметной области, кодируем на Scala для максимальной выразительности и абстракции и используем облачные инструменты, такие как кластеры субъектов, Kubernetes, обмен сообщениями Kafka и инфраструктура как код. Разделение цели породило культуру осознания и совершенства.

📖 Table of contents
RequirementsDomain explorationImmutabilityScaleCultureA shared dream

Требования

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

Отзывчивый, устойчивый, эластичный, управляемый сообщениями

Вы могли узнать четырех участников реактивного манифеста. Для нас это не какая-то полезная философия, а реальные требования. Мы не можем позволить себе, чтобы машины застряли посреди улицы, потому что мы выпускаем некоторые обновления. Когда наступает час пик, и все движутся, нам нужно увеличивать масштабы. И уменьшаться ночью, когда все стихнет. Если где-то в системе произойдет сбой, пассажиров автономного шаттла это не волнует: они все равно хотят добраться до места назначения вовремя.

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

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

Домен-управляемый

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

Наши внутренние модели и механизмы оптимизации - это то, что для нас наиболее ценно. Мы разработали это, пригласив за стол переговоров людей с разным опытом: экспертов в предметной области, инженеров-программистов и математиков, что привело к уникальному сочетанию. Чтобы стимулировать дискуссии, мы используем такие методы, как штурм событий. Компактный и выразительный синтаксис Scala помогает моделировать структуры данных и определять наши предметно-ориентированные языки (DSL) и даже анализаторы текстовых диаграмм. Мы в основном следовали подходам без сохранения состояния для наших протоколов и задач обработки состояний, используя сообщения с реифицируемым представлением эволюции состояния и источников событий (мы расскажем, что это означает ниже).

Исследование домена

Я считаю, что исследование и моделирование предметной области - одно из самых полезных упражнений в разработке программного обеспечения. Сущность каждой концепции должна быть схвачена, взаимодействие между понятиями должно быть описано, потоки должны быть отображены. И поскольку это понимание затем переводится непосредственно в рабочую цифровую систему, состоящую из нулей и единиц, нет места для нечеткости. В конечном итоге все так или иначе всплывет на поверхность, иногда в виде неприятных сюрпризов. Темные углы неопределенности, оставленные позже, могут скрывать самых ужасных монстров, обычно поднимая свой конец незадолго до выхода, когда уровень детализации достаточно увеличился, чтобы пролить на них свет. И тем не менее зачастую невозможно копнуть заранее, и, как мы все знаем, приоритеты постоянно меняются, особенно в стартапах. Анализ предметной области также имеет элемент интуиции, потому что нет рецепта, с чего начать и куда потратить усилия. Более того, желательно правильно угадывать ключевые концепции с самого начала, поскольку они быстро превращаются в услуги. Подобно осям в модельном пространстве, они становятся столпами системы, векторами эволюции и, в конечном итоге, превращаются в продукт и рыночные возможности.

Язык

Когда я начинал свое путешествие в Bestmile, меня часто спасал наш удобный путеводитель: наш внутренний глоссарий. Мы собрали определения терминологии, которые мы ежедневно используем в коде, на собраниях и в обсуждениях. Понятия, которые сначала кажутся простыми, могут скрывать удивительное количество деталей и часто ссылаться на другие концепции, создавая карту нашей области. В нашем глоссарии время поездки, например, определяется как продолжительность поездки пассажира: начинается в конце посадки (сразу после того, как пассажирский поток заканчивается), и заканчивается в начале. высадки (непосредственно перед началом пассажиропотока). Аналогичное определение существует для пассажирского потока. Этот глоссарий определяет наш вездесущий язык, поскольку такой специализированный словарь назван на жаргоне предметно-ориентированного дизайна. Я разделяю убеждение практиков DDD в том, что для разработки соответствующей системы необходим высокий уровень общей осведомленности между экспертами в предметной области и инженерами. В Bestmile каждый инженер является владельцем продукта. Мы проектируем и создаем вместе с общим видением.

Коммуникация

Чтобы собрать людей из разных слоев общества вокруг понимания того, что такое транспорт, мы добились некоторого успеха с помощью штурма событий. Штурм событий - это просто собирание вокруг большой пустой доски и стопки стикеров. Сеанс начинается с идентификации доменных событий (с определенным цветом, обычно оранжевым). Это происходит совершенно естественно, поскольку они отражают заметные события в реальной жизни. Вы быстро увидите появление таких событий, как BookingAccepted, PickupDone, DriveCompleted и т. Д. Когда идеи для мероприятий заканчиваются, команда перемешивает заметки на доске и формирует логические кластеры. Эти центры липкой массы намекают на некую общую концепцию, которой стоит найти название. Эта концепция становится еще одним желтым стикером: агрегатом (термин происходит от предметно-ориентированного дизайна). На этом этапе мы также можем определить триггеры событий. Это команды, которые тоже заслуживают своей наклейки (синие). Также можно добавить больше элементов диаграммы, но удивительно, как много доменов и информационных потоков можно раскрыть только с помощью этих трех (event, command, aggregate ).

Такие сеансы штурма событий не являются техническими, и в них может участвовать любой желающий. У нас были общие идеи, полученные на совместных занятиях с математиками, транспортными экспертами, передовыми и задними разработчиками программного обеспечения, собравшимися в одной комнате (до covid - сделать это удаленно сложнее). Штормы событий по своей природе ориентированы на сообщения и позволяют избежать преждевременного определения границ системы или услуг. Таким образом, они отлично подходят для начала работы над архитектурой микросервисов. События и команды часто преобразуются один к одному в их соответствующие цифровые эквиваленты. Агрегаты часто оказываются хорошими кандидатами на определение границ услуг (но это не должно влиять на анализ).

Выражение

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

Точная типизация - отличный инструмент для реализации наших предположений: например, определяя строго положительную продолжительность в коде, мы накладываем ограничение на логику работы. Компилятор сообщит нам, приводит ли законный случай к нулевой продолжительности, таким образом вызывая более расслабленный неотрицательный тип. Это открытие может, в свою очередь, поставить под сомнение наши предположения о предметной области и привести к важным открытиям. Чем точнее система типов, тем больше выводов, которые мы можем сделать из компилятора, и, как следствие, более глубокие выводы. По этим и другим причинам мы приняли язык Scala, дополненный Refined и другими самодельными библиотеками для типов, которые нам часто требуются. Мы определяем наши структуры данных с алгебраическими типами данных.

📌 Algebraic data types (ADTs) are composite types, i.e., combinations of other types using either a product operation (tuples) or a sum operation (unions). They have many interesting properties: they afford compile-time introspection for mapping code generation, random data generation, serialization automation, or more generally, type class derivation.

Лук репчатый

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

Мы используем черты Scala для определения алгебры, которую мы используем для выражения нашей бизнес-логики, нашего собственного предметно-ориентированного встроенного языка. Типичными элементами этой алгебры являются наши сущности, такие как Booking, Vehicle и т. Д. Мы также описываем эффекты нашего кода с помощью алгебраических конструкций: у нас есть репозитории (например, BookingRepository, VehicleRepository, ...), которые являются абстракциями. в соответствующих таблицах базы данных или кластерах субъектов. Провайдеры представляют некоторую внешнюю службу (например, RouteProvider) или другую абстрактную инфраструктуру (например, Logger для выдачи записей журнала). Наша алгебра также абстрагируется от инкапсуляции значений результатов, в частности значений ошибок. Даже сама цепочка вызовов функций абстрагируется с использованием концепций, полученных из теории категорий, таких как Functor, Applicative и Monad. Эти абстракции композиции функций позволяют позже выбрать синхронную, асинхронную интерпретацию кода домена, в контексте транзакции и т. Д., Не затрагивая пакет домена. Это также делает тесты компактными и удобными для написания. Обычно для тестирования мы выбираем синхронное выполнение, что обеспечивает быструю обратную связь, что удобно для практики TDD.

📌 We have adopted the so-called tagless-final encoding to achieve this level of abstraction. This particular approach is known as "tagless" because it doesn't rely on the program's reification into values, in contrast with other techniques such as free monads where a full AST of the program is constructed within the host language. And final because the program is interpreted directly into the target monad (versus deferred evaluation). This approach has better performance and is quite simple to scaffold, using Scala implicits and context bounds on the container type.

Принятие неудач

Люди склонны не любить ошибки. Большинству из нас нравятся функциональные, блестящие и чистые машины, а может, даже больше - инженерам. Мы, как правило, помешаны на контроле. Поэтому мы обычно сводим к минимуму возможность ошибки. Часто бывает достаточно сложно спроектировать далеко не тривиальные счастливые пути, не говоря уже о том, чтобы учитывать все возможные отклонения. Но, возможно, нам нужно немного изменить точку зрения. Рассматривая ошибки как совершенно допустимые значения в нашей программе, а сценарии ошибок как «альтернативы», а не вырожденные возможности, мы повышаем точность и уровень контроля нашего кода. Для этого нам нужно подготовиться к работе с альтернативными потоками программ. Ошибки больше не должны быть для бедных граждан в наших программах, например, упакованными в единственное ApplicationError(message: String) исключение, пойманное каким-то непроверенным глобальным обработчиком.

Либо

Scala с самого начала хорошо оснащена для функциональной обработки ошибок как значений первого порядка. Тип - это основной инструмент, позволяющий избежать устаревших исключений и исключений, нарушающих ссылочную прозрачность. Either параметризуется двумя ковариантными типами, A и B. Значение Either[A, B] буквально либо A (в этом случае оно называется left), либо B (называется right). Обратите внимание на то, что здесь никогда не всплывает понятие «ошибка». По соглашению, левая сторона обычно используется для представления «менее желательных» значений (также называемых ошибками). В более поздних версиях Scala этот шаблон использования был настолько распространен, что встроенные операторы монадической композиции Either (map, flatMap) были сделаны «смещенными вправо»: either.flatMap(b => ...) выбирает правое значение, в то время как левые операторы остаются доступными через either.left.flatMap(a => ...).

Даже при наличии Either оборудования точная обработка ошибок по-прежнему является проблемой. Обычно наши счастливые пути четко определены, типизированы и покрыты. С другой стороны, ошибки сравнительно расплывчаты, часто описываются несколькими типами, если не одним, с редко используемыми потоками раннего отказа, которые прорезают слои. Принять неудачу сложно, потому что это означает достижение того же уровня точности на «левой» (ошибочной) стороне, что и на правой. Мы должны уловить бестиарий наших «грустных» значений предметной области с надлежащими ADT и иерархиями типов с тем же уровнем точности, что и для «счастливых» значений. Без «табу» в нашем словаре ценностей мы даем возможность бизнес-логике учитывать широкий спектр возможных проявлений. Например, наша служба оптимизации спроса на поездки имеет две категории ошибок: связанные с HTTP, которые мы помещаем в DelegateServiceError, и InfeasibleDemand. В первом случае мы повторяем запрос несколько раз, во втором - нет, так как это стабильный результат.

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

📌 Bifunctor
Recent trends even embrace alternative function values at the code syntax level. There is now some adoption of multi-functorial stacks, as the rising popularity of related projects in the Scala ecosystem attests (ZIO, Monix BIO). In one of our projects, we describe our domain in tagless-final style with a bifunctor, thanks to the help of a small “niche” library (Bfect) which interoperates with Cats.
Either is a good example of a bifunctor: essentially, a container for two mutually exclusive possibilities. 
 As explained earlier, when expressing domain logic in tagless-final style, the concrete functor instance (the actual container type, Either in this case) is not referenced directly but rather via its abstracted abilities (described by the Bifunctor type class). Concretely, this means that as far as the domain algebra is concerned, result values are of a type with two "holes": F[_, _] each type parameter representing one the two mutually exclusive values. 
  Abilities are then indicated using context bounds on this F parameter: compositional characteristics we require (applicative functor for independent computations, monadic functor for sequential computations) and effects such as repository operation, logging, etc. as illustrated in the small code example below.

Явные тексты песен

Выше мы видели, как мы используем tagless-final для определения нашей предметной алгебры, нашего предметно-ориентированного языка (DSL). Мы также широко используем DSL с ограниченным объемом в кодовой базе, в частности, для шаблонов компоновщика. Одним из примеров, которым я могу поделиться, является наш конструктор приложений DSL, который обеспечивает управляемый опыт для создания кластерного приложения Akka с плавным запуском и завершением (мы опишем это более подробно здесь).

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

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

Неизменность

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

  • Службы CRUD (создание, чтение, обновление, удаление): это простейшие, по сути, слой бизнес-логики поверх базы данных. Любое временное состояние является локальным для запроса, часто также заключенным в транзакционные границы. Каждый запрос независим, нет общего состояния вне базы данных, и система является чисто реактивной, то есть она делает что-то только по запросу.
  • Потоковые сервисы: это когда данные входят и выходят, а логика заключается в объединении потоков и отображении / сокращении данных. Время часто само по себе является важным параметром такой конвейерной обработки. Состояние может отображаться, но оно является локальным для этапов конвейера и не является глобальным.
  • Активные службы: такие системы являются наиболее сложными и активно поддерживают некоторый процесс с отслеживанием состояния или представляют собой динамическое взаимодействие объекта с внешним миром. Они реагируют на внешние раздражители, но также активно планируют операции и отправляют сообщения. Внутренние процессы переходят через различные состояния и лучше всего описываются как конечные автоматы.

Поиск событий

Когда мы представляем, что происходит в реальной жизни для конкретной организации, мы получаем последнюю категорию услуг. Типичными активными объектами в нашем облаке являются автомобили, бронирования, поездки, подразделения планирования и т. Д., Которые в действительности обладают высокой степенью актуальности: они меняются со временем в соответствии с определенными правилами. При попытке описать эти естественные переходы на уровне предметной области очень хорошо работает испытанный метод конечного автомата (FSM). Конечный автомат - это набор однозначных состояний и определенных переходов между ними. В этом нет ничего нового: это конструкция, восходящая к истокам вычислений. Но то, что до сих пор не столь обыденно, - это сделать такие маленькие машины с отслеживанием состояния постоянными и довести их количество до миллионов. Одним из ответов на эту проблему является поиск событий в сочетании с моделью акторов для реализации конечного автомата. При поиске событий мы сохраняем переходы нашего конечного автомата в базу данных в виде записей с отметками времени в единой таблице (также известной как журнал событий). Как таковой схемы базы данных нет - хранилище - это просто линейная последовательность событий, журнал. Последовательность переходов воспроизводится из начального числа для получения последнего состояния (или некоторого промежуточного моментального снимка для производительности). Такое получение текущего состояния называется восстановлением. Например, для сущности Booking у нас будут такие события, как RideStarted, RidePickedUp, RideDroppedOff, RideCompleted (и соответствующие переходы в его конечном автомате). Актер представляет каждое резервирование в многоузловом кластере и динамически сбрасывается в память или из нее при необходимости путем восстановления после событий.

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

📌 If you're familiar with front-end development using Redux, all of this might seem strangely familiar. It's no surprise, as it's essentially the same idea: a single, immutable object contains the application state and can only be affected by a defined set of transitions (actions). All interactions in the app translate into actions, thus affording infinite replay (which can be experienced in the redux developer tools).

Потоки и время

Как упоминалось ранее, существуют службы, для которых время является важным параметром. Отчетность - наиболее очевидный пример: подавляющее большинство метрик основано на времени. Одним из примеров многих видов транспорта может служить количество пассажиров, перевезенных за час, количество бронирований в минуту и ​​т. Д. Мы также обрабатываем потоки телеметрических данных в реальном времени, по которым мы скользим окнами вычислений, например, для создания живого средние. Но время также играет важную роль в подсистемах, совершенно не связанных с отчетностью. Например, оркестровка запуска и завершения работы приложения очень чувствительна ко времени (см. Нашу статью для подробного изучения этой, казалось бы, простой, но удивительно сложной темы). Каждый раз, когда время становится важным фактором, повышение уровня абстракции дает нам возможность точно описать, как вещи должны развиваться, а не неловко, ожидая потока управления или возясь с примитивами синхронизации. Мы используем инструменты реактивного программирования, такие как Akka Streams и Monix, для оркестровки потоков данных с помощью богатого набора операторов, учитывающих компонент времени. Операторы позволяют буферизовать, фильтровать и объединять потоки в соответствии с определенными своевременными условиями и т. Д.

📌 In genuine ReactiveX form, Monix even supports “time-traveling” during testing via its scheduler abstraction, so this lets us perform exhaustive checking along the time axis, e.g., to reproduce timeout conditions.

Протоколы без сохранения состояния

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

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

Такая неявная зависимость от времени доставки и порядка сообщений может в конечном итоге превратить серверы и клиентов в полноценные конечные автоматы, которые являются более сложными и требуют постоянства. Реализуя эволюцию статуса в самой структуре данных и делая протокол структурно независимым от времени, мы получаем более надежные и простые реализации (хотя и за счет размера сообщения). Мы называем такие протоколы «апатридами», потому что они не зависят от внешнего состояния или своевременного упорядочивания сообщений. Сообщение является «самодостаточным»: прочитав это единственное сообщение, транспортное средство с первого взгляда узнает все, что необходимо для выполнения своей миссии, и даже свою собственную историю выполнения. Конечно, это создает большие сообщения, формат которых изменяется каждый раз, когда мы добавляем какой-либо аспект в протокол, и приводит к более сложным структурам данных в коде.

📌 We mentioned above the Redux approach for implementing UIs. We can also draw a parallel to it here, in how redux creators made the somewhat counter-intuitive bet of representing the complete application state within a single object for benefits in simplicity and control over evolutions in the state.

Неизменяемые структуры данных

К счастью, у нас есть различные инструменты, которые помогают нам справиться с этой сложностью. Оптика, например, позволяет увеличивать большие структуры данных, чтобы легко манипулировать элементами неизменяемых данных, независимо от того, насколько глубоко они вложены (я написал введение для оптики в Scala здесь). Как упоминалось немного ранее, использование алгебраических типов данных дает нам доступ к расширенным инструментам, основанным на порождении типов: поскольку ADT - это составные типы, состоящие из сумм и произведений, общие операции могут быть получены из примитивов, определенных для базовых типов, таких как String, Int, Boolean и т. Д. Scala имеет мощный механизм вывода типов, который мы можем использовать для встраивания правил в типы и даже для написания программ на уровне типов. Инструкции уровня типа оцениваются во время компиляции (это известно как программирование уровня типа, см. Slick и Shapeless, где приведены отличные примеры). Он также имеет мощную макросистему, встроенную в язык, предоставляющую прямой доступ к AST для еще большей гибкости. Для нас эти причудливые языковые особенности конкретно означают, что мы можем значительно сократить количество шаблонов и двигаться быстрее с минимальными компромиссами в области безопасности.

📌 One boilerplate killer for us is Chimney, which relies on metaprogramming to auto-generate data mapping code based on equivalent naming and type correspondence. Automatic type mapping makes it easier to segregate domain types from API data types or protobuf DTOs while limiting the required boilerplate. Thus, mapping logic boils down to describing exceptional cases when the data shape isn't the same or naming is different. 
  For systematic regression testing, we define property tests with Scalacheck using automatic data generation afforded by Scalacheck-shapeless, combined with Circe-golden. For a certain DTO, we can derive random data generators automatically (from a set of primitives defined for string, int, etc.). Multiple variations of these random instances are fed through serializers, generating a "golden" sample for future comparison. We commit these samples to version control for future reference. 
  So for almost zero coding cost, we get automatic verification of backward compatibility. True enough, auto-generated data is generally unsound and only satisfies the message "shape" (unless using this approach with hand-built generators, but this makes the test sensitive to generator code too). This said, Scalacheck primitives are not purely random and will try to explore corner-cases, so as a bonus, you get coverage for empty strings, empty lists, special chars, large numbers, etc.

Шкала

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

Распределенные приложения

Для каждой службы у нас есть несколько запущенных экземпляров. Эти экземпляры взаимодействуют друг с другом, чтобы по-разному справляться с нагрузкой в ​​зависимости от типа службы. Для CRUD-подобных сервисов с конечными точками REST балансировщик нагрузки распределяет входящие запросы, поскольку каждый запрос является независимым. При получении сообщений от Kafka схема разделения разделяет сообщения между потребителями. В случае сервисов на основе акторов с отслеживанием состояния это намного более детально. Мы полагаемся на технологию шардинга кластера Akka для обеспечения справедливого распределения сущностей между узлами (подробнее об этом ниже).

Управление развертыванием

Kubernetes отвечает за автоматизацию развертывания и горизонтального и вертикального масштабирования наших сервисов. Kubernetes был создан Google и сейчас очень популярен. В этой статье мы обсуждаем абстракции, и оказалось, что Kube также является отличным примером предметно-ориентированного проектирования в ограниченном контексте микросервисной архитектуры. В нем есть точный словарь таких терминов, как Node, Pod, ReplicaSet, Service, Volume и т. Д., Которые отражают тонкости организации приложений в облачной инфраструктуре. Сила Kube также заключается в его гибкости для расширений и богатого API. Мы используем Helm charts вместе с Helmfile, чтобы управлять им.

Конфигурация и продвижение

Мы размещаем наши выпуски в различных средах, которые изолированы с помощью пространств имен Kube. Для нас важно поддерживать воспроизводимость конфигурации, то есть запуск среды не должен включать ручное вмешательство. Мы следуем подходу GitOps, который заключается в хранении и управлении версиями наших Helm-диаграмм в git и использовании запросов на вытягивание в сочетании с конвейерами Codefresh и собственной автоматизацией для продвижения выпусков вперед.

Инфраструктура

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

Разделенный мозг решатель

Как упоминалось выше, для распределенных сервисов с источником событий мы запускаем кластеры акторов с помощью Akka. Механизмы сегментирования распределяют участников по узлам на основе идентификатора объекта и гарантируют, что только один субъект для объекта может существовать одновременно. Узлы в кластере поддерживают постоянную осведомленность о других через протокол сплетен. Один узел выбирается координатором операций сегментирования (обычно самый старый в кластере). Сообщения автоматически направляются к нужному узлу с использованием идентификатора объекта в качестве адреса.

Конечно, членство в кластере очень динамично, и узлы часто уходят и присоединяются. В обычной ситуации скользящего развертывания участники объявляют о своем уходе, и Kube обновляет узлы один за другим, чтобы поддерживать доступность. Сущности, работающие на исходящем узле, «регидратируются» на другом узле, если для них приходят новые сообщения (или если мы настраиваем их так, чтобы они всегда оставались в памяти). Акторы восстанавливаются путем воспроизведения сохраненных событий (с использованием принципов, описанных выше при обсуждении источников событий). Иногда что-то идет не так, и узлы выходят из строя или становятся изолированными от других. Узлы, не отвечающие на биения, в конечном итоге помечаются как недоступные, что приводит к переключению их объектов на другие. Худшая ситуация возникает, когда несколько узлов отделяются от кластера и начинает формироваться новый «мошеннический» кластер. Эта ситуация известна как «разделение мозга», как показано на рисунке ниже:

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

Автоматическое масштабирование и мультитенантность

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

Экосистема инструментов

Масштабирование - это добавление емкости ЦП или памяти и решение растущих операционных проблем и растущих ожиданий. Очень важно работать продуктивно, чтобы не попасть в ловушку постоянного тушения пожаров, особенно с небольшой командой. Чтобы добиться этого, нужно сосредоточиться и хорошо разбираться в некоторых вещах, избегая повторений. Первые шаги на нашем пути к микросервисам были связаны с приручением таких чудовищ, как Kafka, Akka и даже Scala. Мы создали небольшую экосистему вспомогательных библиотек для решения ортогональных проблем, таких как миграция базы данных, обработка географии и т. Д.

Мы также очень гордимся тем, что в нашей команде есть автор прекрасной Библиотеки конечных точек Жюльен Ришар-Фой. Мы большие поклонники алгебраических подходов, и его библиотека дает нам возможность описывать конечные точки REST и данные JSON с помощью алгебры высокого уровня, автоматизируя создание клиентского и серверного кода и даже документации. Нам потребовалось некоторое время, чтобы собрать воедино эти абстракции и инструменты, но теперь мы пользуемся многими преимуществами этого раннего проекта. Во-первых, большая часть наших кодовых баз стала меньше, более единообразной, и если мы исправим какую-то проблему, мы обновим одну библиотеку, и все проекты выиграют от исправления. Мы значительно сократили дублирование между репозиториями по сравнению с тем, что было раньше. И не только это, но теперь гораздо удобнее запускать новую услугу. Уделяя время составлению многократно используемых абстракций, мы также лучше разбираемся в тонкостях каждой технологии. Наши инструменты отражают наши рецепты приготовления и лучшие практики и обеспечивают нам надежную основу, чтобы мы могли сосредоточить свое внимание на реальном бизнесе.

Культура

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

Осведомленность

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

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

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

Совершенство

Позитивность естественным образом подводит меня ко второму пункту - стремлению к совершенству. Совершенство связано с формированием позитивного отношения к работе. Я думаю, будет справедливо сказать, что для большинства из нас менее приятная часть работы инженером-программистом - это регулярно погружаться в непонятную кодовую базу, чтобы исправить непонятную ошибку. Это мало что дает: огромное количество времени на сканирование кода, разочаровывающий процесс проб и ошибок, неутешительные результаты. И все же меня всегда удивляло, как часто мы загоняем себя в этот угол. Обычно мы виним в этом внешние факторы: это естественно, это ужасно! Однако, как бы мы ни жаловались, в том, что код ужасен, виноват не менеджер: его написал не он. Я считаю, что вредоносный код ведет скорее из-за недостатка информации и рекомендаций. Инженеры часто не могут объяснить, насколько сложно определенные вещи, возможно, плохо восприимчивой аудитории. В результате они чувствуют себя вынужденными срезать углы: «это уже было сделано раньше, я сделаю это еще раз».

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

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

Общая мечта

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