Избегаем ада перекомпиляции в Elixir с помощью mix xref

Ад перекомпиляции: каково это

Elixir — удивительный язык, и для меня было огромной привилегией работать с ним уже более десяти лет (как летит время)!

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

Вы вносите несколько изменений в один файл в своей кодовой базе и нажимаете «перекомпилировать». Бум:  Compiling 93 files (.ex). Затем вы вносите еще одно изменение и бум:  Compiling 103 files (.ex).

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

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

Почему это важно

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

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

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

Шаг 1: Обнаружение проблемы

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

Вы можете использовать самый недооцененный инструмент в экосистеме Elixir!

Учебник поmix xref

mix xrefэто, без сомнения, самый недооцененный инструмент в экосистеме Elixir. Думайте о нем как о швейцарском армейском ноже, который дает представление о связях между модулями в вашей кодовой базе.

Шаг 2: Понимание проблемы

Ладно, ты знаешь, что ты в аду перекомпиляции. Но знаешь ли ты,  почему ты в аду? Я предполагаю: ты согрешил с макросами.

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

(Пере)компиляция модуля в Elixir

Давайте раз и навсегда разберемся, как перекомпилируются модули в Elixir.

Сценарий 1: зависимости времени выполнения

В этом первом сценарии есть только вызовы функций времени выполнения: A1 вызывает B1, который вызывает C1.

# lib/a1.ex
defmodule A1 do
def call_b, do: B1.call_c()
end

# lib/b1.ex
defmodule B1 do
def call_c, do: C1.do_something ()
end

# lib/c1.ex
defmodule C1 do
def do_something, do: IO.puts («I did something!»)
end

Вот вывод mix xref --graph:

lib/a1.ex
└── lib/b1.ex
lib/b1.ex
└── lib/c1.ex
lib/c1.ex

Это говорит нам о том, что lib/a1.exимеет зависимость времени выполнения от lib/b1.ex, который в свою очередь имеет зависимость времени выполнения от lib/c1.ex, который не имеет зависимостей.

ПРИМЕЧАНИЕ: Мы можем сказать, что это зависимость времени выполнения, поскольку в mix xrefвыводе нет дополнительной информации рядом с путем к файлу. Если бы это были зависимости времени компиляции, мы бы увидели «lib/a1.ex (compile)».

Вывод перекомпиляции при изменении любого из этих модулей по отдельности:

9a34ae128865eb3e852fe02b48624a28.png

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

Сценарий 2: зависимости времени компиляции

Увы, мы не живем в идеальном мире. Давайте смоделируем зависимость во время компиляции

# lib/a2.ex
defmodule A2 do
@b B2
def call_b, do: @b.say_hello()
end

# lib/b2.ex
defmodule B2 do
@person C2.get_person ()
def say_hello, do: IO.puts («Hello #{@person}»)
end

# lib/c2.ex
defmodule C2 do
def get_person, do: : mike
end

Как вы, вероятно, знаете, атрибуты модуля оцениваются во время компиляции (именно поэтому вы должны использовать Application.compile_env/3атрибуты модуля). Для кода выше это означает следующее:

  • Атрибут @bin A2содержит :Elixir.B2атом, ссылающийся на B2модуль.

  • Атрибут @personin B2содержит :mikeатом, который пришел из вызоваC2.get_person/0

Тест по Elixir: какие модули выше будут иметь зависимость во время компиляции?

lib/a2.ex
└── lib/b2.ex
lib/b2.ex
└── lib/c2.ex (compile)
lib/c2.ex

Как и следовало ожидать,  B2зависит от C2во время компиляции, поскольку если мы изменим результат на C2.person/0, кхм,  :joe,  B2его необходимо будет перекомпилировать, чтобы @personможно было повторно оценить атрибут модуля.

Несколько удивительно,  что неA2 имеет зависимости от времени компиляции. Компилятор достаточно умен, чтобы отслеживать, что любые изменения в модуле никогда не приведут к изменению значения.B2B2@b

Вывод перекомпиляции при изменении любого из этих модулей по отдельности:

70dc6f2a2c9612572502f381dc8e9c44.png

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

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

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

Сценарий 3: транзитивные зависимости времени компиляции

Третий сценарий, который я хотел бы продемонстрировать, это когда ваш код имеет транзитивные зависимости времени компиляции. Если вы страдаете от боли перекомпиляции, вы, вероятно, сталкиваетесь с этим сценарием.

Ниже представлена ​​самая маленькая демонстрация, которую я смог придумать:

defmodule A3 do
@runtime_dependency_on_b B3
def foo, do: :foo
end

defmodule B3 do
@runtime_dependency_on_c C3
end

defmodule C3 do
end

defmodule ClientOne do
@compile_time_dependency_on_a A3.foo ()
end

defmodule ClientTwo do
@compile_time_dependency_on_a A3.foo ()
end

defmodule ClientThree do
@compile_time_dependency_on_a A3.foo ()
end

У вас есть три «клиента». Клиенты — это просто пользователи A3(с обычной зависимостью времени компиляции). A3затем есть зависимость времени выполнения от B3, которая также имеет зависимость времени выполнения от C3. Звучит не так уж плохо, не так ли? Самое главное, что это, похоже, регулярное явление в любой заданной кодовой базе.

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

Вот как выглядит граф компиляции этих модулей:

Кажется безобидным…

Кажется безобидным…

Глядя на график выше, мы ожидаем, что изменения в lib/a3.exприведут к перекомпиляции всех N его клиентов. Действительно, именно это и происходит. Но что вы ожидаете перекомпилировать, если вы измените lib/c3.ex?

Только C3, да? Да, я тоже. Но вот что происходит, когда я меняюсь C3:

Compiling 4 files (.ex)
Compiled lib/c3.ex
Compiled lib/client_three.ex
Compiled lib/client_two.ex
Compiled lib/client_one.ex

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

e46920ade91be48faa7578f668eba38e.png

Хотя мой минимальный пример кажется далеким от реальности, обычно вы сталкиваетесь с такой ситуацией, когда:

  1. У вас есть Core.Eventреализация с макросом

  2. У вас есть несколько Event.MyEventNameмодулей, которые используют макросы изCore.Event

  3. Вы вызываете Services.Logиз макроса

  4. Вы звоните Services.UserизнутриServices.Log

  5. Вы звоните Schemas.UserизнутриServices.User

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

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

Это худшее! Это восьмой круг ада! Он может снизить вашу производительность на несколько порядков! Сделайте себе (и своей команде) одолжение и избавьтесь от них — или внимательно следите за ними , чтобы они не разрастались непреднамеренно.

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

Шаг 3: Устранение проблемы

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

  1. Выявление проблемных модулей в ключевых или общих областях вашей кодовой базы;, а также

  2. Рефакторинг, чтобы не создавать зависимости во время компиляции.

Будет ли это сложно? Это зависит от того, насколько «спагетированы» ваши модули. Если у вас большой ком грязи, распутывание ваших зависимостей, скорее всего, будет болезненным.

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

Выявление транзитивных зависимостей

Поехали! Сначала вам нужно будет определить, какие модули затронуты.

Самое простое, что вы можете сделать, это:

  1. Измените модуль, который запускает перекомпиляцию нескольких других модулей.

  2. Бегать mix compile --verbose.

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

Но мы можем сделать лучше! Мы можем получить полный «путь» между двумя файлами, используя mix xref. Это значительно облегчит вам понимание того, где именно происходит запутывание:

mix xref graph --source SOURCE --sink TARGET

Здесь,  TARGETэто файл, который вы изменили и запустили перекомпиляцию нескольких несвязанных файлов, и SOURCEэто модуль, который, как вы думаете (или знаете), начинает цепочку компиляции. Если вы не знаете, какой модуль является SOURCE, просто используйте последний файл, показанный в mix compile --verboseкоманде, которую вы запустили выше. Вероятно, это то, что SOURCEвам нужно.

Ниже приведен результат для сценария 3

lib/client_one.ex
└── lib/a3.ex (compile)
└── lib/b3.ex
└── lib/c3.ex

В приведенном выше примере, если вы можете удалить зависимость компиляции между client_one.exи a3.exили удалить зависимость времени выполнения между a3и b3, вы разорвете цепочку компиляции.

Избавление от зависимостей времени компиляции

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

Стратегия 1: Переместить макросы в выделенные модули без дополнительных зависимостей

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

# lib/strategy_1/before/event_user_created.ex
defmodule S1.Before.Event.UserCreated do
use S1.Before.Core.Event
@name :user_created_event

def new (user_id), do: %{user_id: user_id}
end
# lib/strategy_1/before/core_event.ex
defmodule S1.Before.Core.Event do
defmacro __using__(_) do
quote do
alias __MODULE__
@before_compile unquote (__MODULE__)
end
end

defmacro __before_compile__(_) do
quote do
@name || raise «You must specify the event name via @name»
def get_name, do: @name
end
end

def emit (%{id: id, data: _data}),
do: S1.After.Services.Log.info («Emitting event_id=#{id}»)
end

# lib/strategy_1/before/service_log.ex
defmodule S1.Before.Services.Log do
def info (value), do: IO.puts (»[info] #{value}»)
end

Обратите внимание, как Services.Logоказывается частью цепочки компиляции просто потому, что он является частью модуля Core.Event, хотя он не играет особой роли в самом макросе. Команда ниже сообщает нам, что существует транзитивная зависимость ( mix refназывает их «компилируемо-связанными») между event_user_created.exиcore_event.ex

> mix xref graph --label compile-connected | grep before

lib/strategy_1/before/event_user_created.ex
└── lib/strategy_1/before/core_event.ex (compile)

Эта стратегия заключается в разбиении Core.Eventна две части:  Core.Event.Definitionс макросами и Core.Eventс общими функциями.

# lib/strategy_1/after/core_event.ex
defmodule S1.After.Core.Event do
def emit(%{id: id, data: _data}),
do: S1.After.Services.Log.info("Emitting event_id=#{id}")
end

# lib/strategy_1/after/core_event_definition.ex
defmodule S1.After.Core.Event.Definition do
defmacro __using__(_) do
quote do
alias Core.Event
@before_compile unquote (__MODULE__)
end
end

defmacro __before_compile__(_) do
quote do
@name || raise «You must specify the event name via @name»
def get_name, do: @name
end
end
end

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

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

Применив эту стратегию, мы теперь свободны от транзитивных зависимостей:

> mix xref graph --label compile-connected | grep after

(empty output, meaning there are no transitive dependencies)

Полный пример вы можете найти на Github .

Стратегия 2: Ссылка на модуль во время выполнения (пример Absinthe)

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

# lib/strategy_2/before/queries.ex
defmodule S2.before.queries do
use Absinthe.Schema.Notation

object: queries do
field: user, : user do
arg (: id, : integer)

# We are calling the resolver directly, as any sane person would
resolve (&S2.Before.Resolver.query_user/3)
end
end
end

Вот альтернатива:

# lib/strategy_2/after/queries.ex
defmodule S2.After.Queries do
use Absinthe.Schema.Notation

object: queries do
field: user, : user do
arg (: id, : integer)

resolve (fn parent, args, resolution →
# Here, we remove the compile-time reference by building
# the resolver module dynamically
resolver ().query_user (parent, args, resolution)
end)
end
end

# Either option below works
defp resolver, do: : «Elixir.S2.Before.Resolver»
# defp resolver, do: Module.concat (S2.Before, Resolver)
end

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

Недостатки этого подхода:

  1. Хуже читаемость кода, так как вы делаете что-то неожиданное.

  2. Хуже производительность, так как требуются дополнительные вызовы функций.

Стоят ли недостатки преимуществ отсутствия цепочки компиляции? Почти всегда да! Ухудшение читаемости можно абстрагировать (как видно из примера). Накладные расходы на производительность в большинстве случаев незначительны (один SQL-запрос будет как минимум в 10 000 раз медленнее).

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

Полный пример вы можете найти на Github .

Стратегия 3: Сохраняйте простоту макросов

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

Стратегия 4: Не используйте макросы

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

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

Шаг 4: Предотвращение повторения этой ситуации

После большой работы вам удалось избавиться от цепочек компиляции! Поздравляем! Ваши коллеги будут очень благодарны за ваш рефакторинг.

Обнаружение транзитивных зависимостей в вашем конвейере CI

Во-первых, вам нужно узнать, сколько цепей у вас в данный момент:

mix xref graph --label compile-connected | grep "(compile)" | wc -l

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

Затем вы можете применить следующую проверку в своем конвейере:

- name: Check for transitive compilation dependencies
run: mix xref graph --label compile-connected --fail-above NUMBER

Замените NUMBERтекущим (или целевым) числом транзитивных зависимостей в вашей кодовой базе. Ваша цель должна быть равна нулю.

Следуя этому методу, вы предотвратите:

  • появления новых цепей.

  • существующие сети растут.

    Подождите! У вас ведь есть конвейер CI, верно? Если у вас его нет, но вы используете Github, просто добавьте этот базовый шаблон рабочего процесса. Он компилирует, тестирует, проверяет форматирование и транзитивные зависимости. Это хорошая отправная точка. Github Actions бесплатен, включая частные репозитории.

    Стоит упомянуть

    Сопоставление структур с образцом больше не создает зависимости во время компиляции.

    Если вы работаете с Elixir уже несколько лет, вы, возможно, помните, что раньше это было проблемой:

    defmodule Services.User do
    alias Schemas.User

    def query (%User{id: id}) do
    # …
    end
    end

    Приведенный выше код создаст зависимость времени компиляции между Services.Userи Schemas.User. Начиная с Elixir 1.11 (~2020) это больше не так. Теперь этот тип сопоставления с образцом создает зависимость «экспорта», которая вызовет перекомпиляцию только в случае изменения структурыSchemas.User .

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

    Не полагайтесь (слепо) mix xrefна границы

    Как вы видели модуль может ссылаться на другой динамически. Когда это происходит,  mix xrefон не может сказать вам, что Aможет вызывать B.

    Вывод : не стоит слепо доверять mix xrefвыходным данным, особенно если вы пытаетесь обеспечить безопасность/границы между модулями.

    Визуализация ваших зависимостей с помощью DepVizиGraphviz

    Есть удивительный инструмент, который вам стоит использовать прямо сейчас:  DepViz .

    1. Просто зайдите на https://depviz.jasonaxelson.com/ .

    2. Создайте свой .dotфайл с расширением mix xref graph --format dot.

    3. Загрузите и визуализируйте свою понятную, хорошо организованную структуру модулей.

    В качестве альтернативы вы можете просто использовать Graphviz :

    1. Создайте свой .dotфайл с расширением mix xref graph --format dot.

    2. Создайте .svgсвой график с помощью dot -Tsvg xref_graph.dot -o xref_graph.svg.

    3. Откройте его в своем браузере (или где-либо еще) и визуализируйте иерархию модулей.

      Заключение

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

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

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

© Habrahabr.ru