Избегаем ада перекомпиляции в 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)».
Вывод перекомпиляции при изменении любого из этих модулей по отдельности:
Зависимости времени выполнения хороши, поскольку это означает, что изменение одного модуля потребует перекомпиляции только этого одного модуля. В идеальном мире каждый модуль в вашей кодовой базе будет зависеть только от других модулей во время выполнения.
Сценарий 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
атрибуты модуля). Для кода выше это означает следующее:
Атрибут
@b
inA2
содержит:Elixir.B2
атом, ссылающийся наB2
модуль.Атрибут
@person
inB2
содержит: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
Вывод перекомпиляции при изменении любого из этих модулей по отдельности:
Стоит ли нам беспокоиться об этих зависимостях времени компиляции? Лично я бы не стал слишком беспокоиться, пока они не станут заметной проблемой. Они могут стать заметными, когда вы начнете, например, вызывать определенную функцию из атрибута модуля в нескольких модулях.
Причина, по которой я бы не стал беспокоиться изначально , заключается в том, что этот тип зависимости во время компиляции обычно легко разрешить. Так что вперед, позвольте вашей кодовой базе развиваться и расти, и если у вас появятся какие-то острые углы, вы сможете исправить их позже.
Конечно, это не значит, что можно пренебречь этим. При написании или просмотре кода всегда уделяйте особое внимание атрибутам модуля. Если вы видите зависимость компиляции, которая может стать неприятной в будущем — или которую можно легко удалить прямо сейчас — продолжайте и избавьте себя от будущих проблем.
Сценарий 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
Ой, нехорошо. То, что мы здесь наблюдаем, — это транзитивные зависимости времени компиляции в действии.
Хотя мой минимальный пример кажется далеким от реальности, обычно вы сталкиваетесь с такой ситуацией, когда:
У вас есть
Core.Event
реализация с макросомУ вас есть несколько
Event.MyEventName
модулей, которые используют макросы изCore.Event
Вы вызываете
Services.Log
из макросаВы звоните
Services.User
изнутриServices.Log
Вы звоните
Schemas.User
изнутриServices.User
Каждый раз, когда вы меняете схему пользователя, вы также перекомпилируете каждое из своих событий.
Теперь представьте, что вы следуете похожему шаблону с конечными точками, определениями заданий, схемами, авторизацией, обработкой ошибок, проверкой данных, аналитикой. Вы можете легко оказаться в ситуации, когда все запутано в паутине модулей. Одно изменение по сути перекомпилирует каждый отдельный модуль в вашей кодовой базе.
Это худшее! Это восьмой круг ада! Он может снизить вашу производительность на несколько порядков! Сделайте себе (и своей команде) одолжение и избавьтесь от них — или внимательно следите за ними , чтобы они не разрастались непреднамеренно.
На практике, худшими нарушителями этого являются макросы. Вы можете быть затронуты, даже если не пишете макросы сами: библиотеки «нагруженные макросами», в частности, склонны к транзитивным зависимостям (Absinthe стоит упомянуть по имени: каждая кодовая база, которую я видел, использующая Absinthe, страдает от этого).
Шаг 3: Устранение проблемы
В этот момент вы знаете, что вы в аду, и что еще важнее, вы знаете, почему вы в аду. Как же вы можете выбраться? По сути, все сводится к двум простым шагам:
Выявление проблемных модулей в ключевых или общих областях вашей кодовой базы;, а также
Рефакторинг, чтобы не создавать зависимости во время компиляции.
Будет ли это сложно? Это зависит от того, насколько «спагетированы» ваши модули. Если у вас большой ком грязи, распутывание ваших зависимостей, скорее всего, будет болезненным.
Однако, какой бы сложной ни оказалась эта задача, я могу гарантировать, что она определенно стоит усилий. И как только вы ее распутаете, если продолжите читать этот блог до конца, вы узнаете, что нужно сделать, чтобы предотвратить возникновение транзитивных зависимостей в будущем.
Выявление транзитивных зависимостей
Поехали! Сначала вам нужно будет определить, какие модули затронуты.
Самое простое, что вы можете сделать, это:
Измените модуль, который запускает перекомпиляцию нескольких других модулей.
Бегать
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 лет и не помню ни одного случая, когда бы она вызывала несоответствия.
Недостатки этого подхода:
Хуже читаемость кода, так как вы делаете что-то неожиданное.
Хуже производительность, так как требуются дополнительные вызовы функций.
Стоят ли недостатки преимуществ отсутствия цепочки компиляции? Почти всегда да! Ухудшение читаемости можно абстрагировать (как видно из примера). Накладные расходы на производительность в большинстве случаев незначительны (один 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.Userdef 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 .
Просто зайдите на https://depviz.jasonaxelson.com/ .
Создайте свой
.dot
файл с расширениемmix xref graph --format dot
.Загрузите и визуализируйте свою понятную, хорошо организованную структуру модулей.
В качестве альтернативы вы можете просто использовать Graphviz :
Создайте свой
.dot
файл с расширениемmix xref graph --format dot
.Создайте
.svg
свой график с помощьюdot -Tsvg xref_graph.dot -o xref_graph.svg
.Откройте его в своем браузере (или где-либо еще) и визуализируйте иерархию модулей.
Заключение
Независимо от того, являетесь ли вы техническим директором, техническим руководителем или отдельным членом команды, пожалуйста, ради вашего же блага обращайте внимание на цикл обратной связи.
Начиная новый проект, опытный инженер будет пристально следить за этим, поскольку он знает, насколько важно в долгосрочной перспективе иметь быстрый цикл. Однако менее опытные инженеры или опытные инженеры, не имеющие большого опыта работы с Elixir, могут не осознавать, что они вредят циклу обратной связи, пока не станет слишком поздно.
Оптимизируйте цикл обратной связи, через который ежедневно проходят ваши разработчики (или вы сами). Я настаиваю: когда дело касается производительности, это первая метрика, о которой вы должны заботиться.