Экскурсия по нашей 250-тысячной кодовой базе Clojure

Экскурсияпонашей250тысячнойкодовойбазеclojure

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

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

Пользовательский язык в пределах языка

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

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

То, что вы можете создать в Clojure совершенно новый язык с радикально другой семантикой, демонстрирует, насколько мощным является Clojure. При построении языка таким способом вы получаете многое «бесплатно»: лексирование, синтаксический анализ, типы данных, пространства имен, неизменяемые структуры данных и всю библиотечную экосистему Clojure и JVM. В конечном итоге наш новый язык – это Clojure, поскольку он определен в Clojure, поэтому он выигрывает от бесшовной совместимости как с Clojure, так и с JVM.

Подавляющее большинство приложений не нужно будет разрабатывать полноценный язык, как у нас. Но есть множество вариантов использования, в которых уместен специализированный DSL, и у нас тоже есть примеры. Возможность при использовании Clojure настраивать интерпретацию самого кода с помощью макросов и метапрограммирования – невероятно мощная возможность.

Проверка типа / схемы

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

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

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

Вокруг схемы у нас есть помощник под названием «defrecord +», который определяет функции конструктора, которые также выполняют проверку (например, для типа Foo он генерирует «-> valid-Foo» и «map-> valid-Foo»). Эти функции генерируют описательное исключение, если проверка схемы завершается неудачно.

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

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

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

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

    Настройка многомодульного репозитория

    Наша кодовая база существует в одном репозитории git с четырьмя модулями для разделения реализации:

  • «ядро», которое содержит определение нашего компилятора и соответствующие абстракции для параллельного программирования
  • “распределенный”, который реализует эти абстракции параллельного программирования как распределенный кластер
  • “rpl-specter”, внутренний форк Призрак который добавляет массу функциональных возможностей
    • «webui», который реализует интерфейс нашего продукта

    • Мы используем

      Leiningen а также deps.edn для нашей сборки. Возможность указывать локальные цели как зависимости в файлах deps.edn является ключом к нашей многомодульной настройке, а базовая организация нашего исходного дерева выглядит так:

  • 1

    core / project.clj


    2

    3

    4

    5

    6

    7

    8

    9

    13

    проект. clj

    deps.edn

    rpl-specter / project.clj

    rpl-specter / deps .edn

    core / deps.edn

    распределенный / project.clj


    распределенный / deps.edn

    webui / project.clj

    webui / deps.edn

    Вот отрывок из нашего файла deps.edn для «распределенных»:

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

    Загрузка всей кодовой базы для запуска тестов или загрузки REPL выполняется довольно медленно (в основном из-за компиляции кода с использованием нашей системы хранения данных). m), поэтому мы активно используем компиляцию AOT для ускорения разработки. Поскольку мы проводим большую часть времени в «распределенной» разработке, мы AOT компилируем «ядро», чтобы ускорить процесс. Полиморфные данные с помощью Spectre

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

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

    1

    2

    (

    defprotocol NeededFields


    (нужный

    поля

    )

    )

    Проблема с этим подходом в том, что он охватывает только запросы. Некоторые этапы нашего компилятора должны переписывать поля во всем абстрактном представлении (например, уникальные переменные для удаления затенения), и этот протокол не поддерживает это. К этому протоколу можно добавить метод (set-required-fields [this fields]), но он не совсем соответствует типам данных, которые имеют фиксированное количество полей ввода. Он также не подходит для вложенных манипуляций.

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

    1

    2


    3

    4

    5

    6

    7

    8

    9

    13

    13

    15

    16

    17

    18

    19

    20

    21

    23

    22

    22

    23

    25

    25

    30

    60


    ( defprotocolpath Необходимые поля

    []

    )

    ( defrecord + OperationInput

    [fields : [(s/pred opvar?)]

    применять? :

    Логическое

    ]

    )

    ( defrecord + Вызов

    [op : (s/condpre (s/pred opvar?) IFn RFn)

    input : OperationInput] )

    (продлевать Protocolpath NeededFields Invoke

    ( мульти

    дорожка [:op opvar?]

    [:input :fields ALL]

    ) )

    ( + VarAnnotation


    [var : (s/pred opvar?)

    options : {s/Keyword Object}]

    )

    (продлевать

    – protocolpath NeededFields VarAnnotation


    : var

    )

    (

    defrecord + Режиссер

    [producer : (s/condpre (s/pred opvar?) PFn)]

    )

    (продлевать

    protocolpath NeededFields Producer


    [:producer opvar?]

    )


    Например, «Invoke» – это тип, представляющий вызов другой функции. Поле: op может быть статической функцией или ссылкой var на функцию в замыкании. Другой путь ведет ко всем полям, используемым в качестве аргументов для вызова функции.

    Эта структура чрезвычайно гибкая и позволяет выражать модификации так же легко, как и запросы, путем прямой интеграции со Spectre. Например, мы можем добавить суффикс «-foo» ко всем необходимым полям в следующей последовательности операций:

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

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

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

    1

    2


    3

    4

    5

    6

    7

    8

    9

    14


    ( defrcomponent AdminUiWebserver

    {:

    в этом [port]

    :

    deps

    [metastore

    servicehandler

    clusterretriever]

    :

    сгенерированные

    [^org.eclipse.jetty.server.Server jettyinstance] }

    составная часть

    / Жизненный цикл



    )


    Это автоматически извлекает поля “хранилище метаданных”, «обработчик службы» и «средство извлечения кластера» из карты системы, в которой он запущен, и делает их доступными при закрытии реализации компонента. Он ожидает одно поле «порт» в конструкторе компонента и генерирует другое поле «jetty-instance» при запуске во внутреннем закрытии.

    Мы также расширили парадигму жизненного цикла компонентов с помощью «start-async» и «stop -async “методы протокола. Некоторые компоненты выполняют часть своей инициализации / удаления в других потоках, и для остальной части нашей системы (особенно детерминированного моделирования, описанного ниже) было важно, чтобы они выполнялись неблокирующим образом.

    Наша тестовая инфраструктура строит на Компонент для выполнения инъекции зависимостей. Например, из нашего тестового кода:

    1

    2

    3

    4

    5

    6

    7

    8


    ( sc / с участием

    смоделированные

    кластер

    [{:ticker (rcomponent/noopcomponent)}

    {:keys [clustermanager

    executorservicefactory

    metastore]

    : в виде полный

    – система

    }

    ]

    )


    Эта первая карта является картой внедрения зависимостей, и этот код отключает компонент «тикер». «Тикер» заставляет тесты моделирования время от времени опережать время, и, поскольку этот тест явно хочет контролировать время, он отключает его. Эта карта внедрения зависимостей может использоваться для переопределения или отключения любого компонента в системе, обеспечивая гибкость, необходимую для написания тестов. Использование with-redefs для тестирования

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

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

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

    Вот один пример (из сотен в нашей кодовой базе) того, как мы используем этот шаблон. Одна часть нашей системы выполняет заданную пользователем работу распределенным образом, и ей необходимо: 1) повторить работу, если она не удалась, и 2) установить контрольную точку своего прогресса в устойчивом реплицированном хранилище после успешного выполнения порогового объема работы. Один из тестов для этого вводит ошибку при первой попытке работы, а затем проверяет, что система повторяет работу.

    Исходная функция, выполняющая работу, называется «данные процесса!», И вот отрывок из этого функция:

    «повтор – успешно. “- это не выполняемая функция, определенная как (defn retry-success []).

    В совершенно отдельной функции, называемой “состояние контрольной точки!” , после завершения реплики вызывается безоперационная функция “стабильное состояние-контрольная точка”. ting и запись на диск информации о ходе выполнения. В нашем тестовом коде мы имеем:

    1

    2

    3

    4

    5

    6

    7

    8

    9

    13


    (

    deftest повторить попытку Пользователь

    Работа смоделированные интеграция

    контрольная работа

    ( позволять

    [checkpoints (volatile! 0)

    retrysuccesses (volatile! 0)]

    (

    с участием переопределения [менеджер / прочный

    – государственный

    контрольная точка

    ( fn

    []

    ( vswap ! контрольно-пропускные пункты вкл

    )

    )

    менеджер / повторить попытку

    выполнено успешно

    (

    fn []

    ( vswap ! повторить попытку

    успехов

    вкл.

    ) )

    ]



    )

    )

    )


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

    Лучше всего, так как этот подход к журналу событий à la carte основан на бесполезных функциях, он практически не добавляет накладных расходов, когда код запускается в производственной среде. . Мы обнаружили, что этот подход является невероятно мощной техникой тестирования, которая уникальным образом использует дизайн Clojure.

    Использование макросов

    У нас примерно 500 макросы, определенные в нашей кодовой базе, 250% из которых являются частью исходного кода и 70% из которых предназначены только для тестового кода. Мы нашли общий совет для макросов, например, не используйте макрос, когда вы можете использовать функцию, как мудрое руководство. Что у нас есть 478 макросы делают то, что вы можете ‘ Работа с обычными функциями демонстрирует, насколько мы создаем абстракции, которые выходят далеко за рамки того, что вы можете сделать с помощью типичного языка, не имеющего мощной макросистемы.

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

    О 70 наших макросов определяют абстракции нашего настраиваемого языка. Во всех этих случаях интерпретация внутренних форм отличается от ванильной Clojure.

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

    Этот код расширяется до:

    1

    ( позволять

    2

    3

    4


    [a (mkathing)

    _ (dosomething! a)

    b (mkanotherthing)]

    (является

    (

    знак равно ( foo b

    )

    (

    кроме ) )

    )

    )


    Остальные макросы сочетание внутренних абстракций, таких как построенный нами DSL с конечным автоматом, и различных идиосинкразических деталей реализации, в которых макрос удаляет дублирование кода, которое нельзя удалить другим способом.

    Макросы – это языковая функция, которой можно злоупотреблять для создания ужасно запутанного кода. , или их можно использовать для создания фантастически элегантного кода. Как и во всем остальном в разработке программного обеспечения, конечный результат зависит от навыков тех, кто его использует. В Red Planet Labs мы не можем представить себе создание программных систем без макросов в нашем наборе инструментов.

    Детерминированное моделирование

    В виде

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

    Leave a comment

    Your email address will not be published. Required fields are marked *