Мне, с того момента, как мы объявили о том, что в npm 7 будут поддерживаться файлы yarn.lock
, несколько раз задавали один и тот же вопрос. Он звучал так: «Зачем тогда оставлять поддержку package-lock.json
? Почему бы не использовать только yarn.lock
?».
Краткий ответ на этот вопрос выглядит так: «Потому что yarn.lock
не полностью удовлетворяет нуждам npm. Если полагаться исключительно на него, это ухудшит возможности npm по формированию оптимальных схем установки пакетов и возможности по добавлению в проект нового функционала». Ответ более подробный представлен в данном материале.
Базовая структура файла yarn.lock
Файл yarn.lock
представляет собой описание соответствия спецификаторов зависимостей пакетов и метаданных, описывающих разрешение этих зависимостей. Например:
mkdirp@1.x:
version "1.0.2"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.2.tgz#5ccd93437619ca7050b538573fc918327eba98fb"
integrity sha512-N2REVrJ/X/jGPfit2d7zea2J1pf7EAR5chIUcfHffAZ7gmlam5U65sAm76+o4ntQbSRdTjYf7qZz3chuHlwXEA==
В этом фрагменте сообщается следующее: «Любая зависимость от mkdirp@1.x
должна разрешаться именно в то, что указано здесь». Если несколько пакетов зависят от mkdirp@1.x
, то все эти зависимости будут разрешены одинаково.
В npm 7, если в проекте существует файл yarn.lock
, npm будет пользоваться содержащимися в нём метаданными. Значения полей resolved
сообщат npm о том, откуда ему нужно загружать пакеты, а значения полей integrity
будут использоваться для проверки того, что получено, на предмет соответствия этого тому, что ожидалось получить. Если пакеты добавляются в проект или удаляются из него, соответствующим образом обновляется содержимое yarn.lock
.
Npm при этом, как и прежде, создаёт файл package-lock.json
. Если в проекте присутствует этот файл, он будет использоваться как авторитетный источник сведений о структуре (форме) дерева зависимостей.
Вопрос тут заключается в следующем: «Если yarn.lock
достаточно хорош для менеджера пакетов Yarn — почему npm не может просто использовать этот файл?».
Детерминированные результаты установки зависимостей
Результаты установки пакетов с помощью Yarn гарантированно будут одними и теми же при использовании одного и того же файла yarn.lock
и одной и той же версии Yarn. Применение различных версий Yarn может привести к тому, что файлы пакетов на диске будут расположены по-разному.
Файл yarn.lock
гарантирует детерминированное разрешение зависимостей. Например, если foo@1.x
разрешается в foo@1.2.3
, то, учитывая использование одного и того же файла yarn.lock
, это будет происходить всегда, во всех версиях Yarn. Но это (как минимум, само по себе) не эквивалентно гарантии детерминированности структуры дерева зависимостей!
Рассмотрим следующий граф зависимостей:
root -> (foo@1, bar@1)
foo -> (baz@1)
bar -> (baz@2)
Вот пара схем деревьев зависимостей, каждое из которых можно признать корректным.
Дерево №1:
root
+-- foo
+-- bar
| +-- baz@2
+-- baz@1
Дерево №2:
+-- foo
| +-- baz@1
+-- bar
+-- baz@2
Файл yarn.lock
не может сообщить нам о том, какое именно дерево зависимостей нужно использовать. Если в пакете root
будет выполнена команда require(«baz»)
(что некорректно, так как эта зависимость не отражена в дереве зависимостей), файл yarn.lock
не гарантирует правильного выполнения этой операции. Это — форма детерминизма, которую может дать файл package-lock.json
, но не yarn.lock
.
На практике, конечно, так как у Yarn, в файле yarn.lock
, есть вся информация, необходимая для того чтобы выбрать подходящую версию зависимости, выбор является детерминированным до тех пор, пока все используют одну и ту же версию Yarn. Это означает, что выбор версии всегда делается одним и тем же образом. Код не меняется до тех пор, пока кто-нибудь его не изменит. Надо отметить, что Yarn достаточно интеллектуален для того, чтобы, при создании дерева зависимостей, не зависеть от расхождений, касающихся времени загрузки манифеста пакета. Иначе детерминированность результатов гарантировать было бы нельзя.
Так как это определяется особенностями алгоритмов Yarn, а не структурами данных, имеющимися на диске (не идентифицирующих алгоритм, который будет использован), эта гарантия детерминизма, в своей основе, слабее, чем гарантия, которую даёт package-lock.json
, содержащий полное описание структуры дерева зависимостей, хранящегося на диске.
Другими словами, на то, как именно Yarn строит дерево зависимостей, влияют файл yarn.lock
и реализация самого Yarn. А в npm на то, каким будет дерево зависимостей, влияет только файл package-lock.json
. Благодаря этому структуру проекта, описанную в package-lock.json
, становится сложнее случайно нарушить, пользуясь разными версиями npm. А если же в файл будут внесены изменения (может быть — по ошибке, или намеренно), эти изменения будут хорошо заметны в файле при добавлении его изменённой версии в репозиторий проекта, в котором используется система контроля версий.
Вложенные зависимости и дедупликация зависимостей
Более того, существует целый класс ситуаций, предусматривающих работу с вложенными зависимостями и дедупликацию зависимостей, когда файл yarn.lock
не способен точно отразить результат разрешения зависимостей, который будет, на практике, использоваться npm. Причём, это справедливо даже для тех случаев, когда npm использует yarn.lock
в качестве источника метаданных. В то время как npm использует yarn.lock
как надёжный источник информации, npm не рассматривает этот файл в роли авторитетного источника сведений об ограничениях, накладываемых на версии зависимостей.
В некоторых случаях Yarn формирует дерево зависимостей с очень высоким уровнем дублирования пакетов, а нам это ни к чему. В результате оказывается, что точное следование алгоритму Yarn в подобных случаях — это далеко не идеальное решение.
Рассмотрим следующий граф зависимостей:
root -> (x@1.x, y@1.x, z@1.x)
x@1.1.0 -> ()
x@1.2.0 -> ()
y@1.0.0 -> (x@1.1, z@2.x)
z@1.0.0 -> ()
z@2.0.0 -> (x@1.x)
Проект root
зависит от версий 1.x
пакетов x
, y
и z
. Пакет y
зависит от x@1.1
и от z@2.x
. У пакета z
версии 1 нет зависимостей, но этот же пакет версии 2 зависит от x@1.x
.
На основе этих сведений npm формирует следующее дерево зависимостей:
root (x@1.x, y@1.x, z@1.x) <-- здесь зависимость x@1.x
+-- x 1.2.0 <-- x@1.x разрешается в 1.2.0
+-- y (x@1.1, z@2.x)
| +-- x 1.1.0 <-- x@1.x разрешается в 1.1.0
| +-- z 2.0.0 (x@1.x) <-- здесь зависимость x@1.x
+-- z 1.0.0
Пакет z@2.0.0
зависит от x@1.x
, то же самое можно сказать и о root
. Файл yarn.lock
сопоставляет x@1.x
c 1.2.0
. Однако зависимость пакета z
, где тоже указано x@1.x
, вместо этого, будет разрешена в x@1.1.0
.
В результате, даже хотя зависимость x@1.x
описана в yarn.lock
, где указано, что она должна разрешаться в версию пакета 1.2.0
, имеется второй результат разрешения x@1.x
в пакет версии 1.1.0
.
Если запустить npm с флагом --prefer-dedupe
, то система пойдёт на шаг дальше и установит лишь один экземпляр зависимости x
, что приведёт к формированию следующего дерева зависимостей:
root (x@1.x, y@1.x, z@1.x)
+-- x 1.1.0 <-- x@1.x для всех зависимостей разрешается в версию 1.1.0
+-- y (x@1.1, z@2.x)
| +-- z 2.0.0 (x@1.x)
+-- z 1.0.0
Это минимизирует дублирование зависимостей, получившееся дерево зависимостей фиксируется в файле package-lock.json
.
Так как файл yarn.lock
фиксирует лишь порядок разрешения зависимостей, а не результирующее дерево пакетов, Yarn сформирует такое дерево зависимостей:
root (x@1.x, y@1.x, z@1.x) <-- здесь зависимость x@1.x
+-- x 1.2.0 <-- x@1.x разрешается в 1.2.0
+-- y (x@1.1, z@2.x)
| +-- x 1.1.0 <-- x@1.x разрешается в 1.1.0
| +-- z 2.0.0 (x@1.x) <-- x@1.1.0 тут бы подошёл, но...
| +-- x 1.2.0 <-- Yarn создаёт дубликат ради выполнения того, что описано в yarn.lock
+-- z 1.0.0
Пакет x
, при использовании Yarn, появляется в дереве зависимостей три раза. При применении npm без дополнительных настроек — 2 раза. А при использовании флага --prefer-dedupe
— лишь один раз (хотя тогда в дереве зависимостей оказывается не самая новая и не самая лучшая версия пакета).
Все три получившихся дерева зависимостей можно признать корректными в том смысле, что каждый пакет получит те версии зависимостей, которые соответствуют заявленным требованиям. Но нам не хотелось бы создавать деревья пакетов, в которых слишком много дубликатов. Подумайте о том, что будет, если x
— это большой пакет, у которого есть много собственных зависимостей!
В результате имеется единственный способ, используя который, npm может оптимизировать дерево пакетов, поддерживая, в то же время, создание детерминированных и воспроизводимых деревьев зависимостей. Этот способ заключается в применении lock-файла, принцип формирования и использования которого на фундаментальном уровне отличается от yarn.lock
.
Фиксация результатов реализации намерений пользователя
Как уже было сказано, в npm 7 пользователь может использовать флаг --prefer-dedupe
для того чтобы был бы применён алгоритм генерирования дерева зависимостей, при выполнении которого приоритет отдаётся дедупликации зависимостей, а не стремлению всегда устанавливать самые свежие версии пакетов. Применение флага --prefer-dedupe
обычно идеально подходит в ситуациях, когда дублирование пакетов нужно свести к минимуму.
Если используется этот флаг, то итоговое дерево для вышеприведённого примера будет выглядеть так:
root (x@1.x, y@1.x, z@1.x) <-- здесь зависимость x@1.x
+-- x 1.1.0 <-- x@1.x разрешается в 1.1.0 во всех случаях
+-- y (x@1.1, z@2.x)
| +-- z 2.0.0 (x@1.x) <-- здесь зависимость x@1.x
+-- z 1.0.0
В данном случае npm видит, что даже хотя x@1.2.0
— это самая свежая версия пакета, удовлетворяющая требованию x@1.x
, вместо неё вполне можно выбрать x@1.1.0
. Выбор этой версии приведёт к меньшему уровню дублирования пакетов в дереве зависимостей.
Если не фиксировать структуру дерева зависимостей в lock-файле, то каждому программисту, работающему над проектом в команде, пришлось бы настраивать свою рабочую среду точно так же, как её настраивают остальные члены команды. Только это позволит ему получить тот же результат, что и остальные. Если «реализация» механизма построения дерева зависимостей может быть изменена подобным способом, это даёт пользователям npm серьёзные возможности по оптимизации зависимостей в расчёте на собственные специфические нужды. Но, если результаты создания дерева зависят от реализации системы, это делает невозможным создание детерминированных деревьев зависимостей. Именно к этому приводит использование файла yarn.lock
.
Вот ещё несколько примеров того, как дополнительные настройки npm способны приводить к созданию отличающихся друг от друга деревьев зависимостей:
--legacy-peer-deps
, флаг, который заставляет npm полностью игнорироватьpeerDependencies
.--legacy-bundling
, флаг, говорящий npm о том, что он не должен даже пытаться сделать дерево зависимостей более «плоским».--global-style
, флаг, благодаря которому всех транзитивные зависимости устанавливаются в виде вложенных зависимостей, в папках зависимостей более высокого уровня.
Захват и фиксация результатов разрешения зависимостей и расчёт на то, что при формировании дерева зависимостей будет использован один и тот же алгоритм, не работают в условиях, когда мы даём пользователям возможность настраивать механизм построения дерева зависимостей.
Фиксация же структуры готового дерева зависимостей позволяет нам давать в распоряжение пользователей подобные возможности и при этом не нарушать процесс построения детерминированных и воспроизводимых деревьев зависимостей.
Производительность и полнота данных
Файл package-lock.json
приносит пользу не только тогда, когда нужно обеспечить детерминированность и воспроизводимость деревьев зависимостей. Мы, кроме того, полагаемся на этот файл для отслеживания и хранения метаданных пакетов, значительно экономя время, которое иначе, с использованием только package.json
, ушло бы на работу с реестром npm. Так как возможности файла yarn.lock
сильно ограничены, в нём нет метаданных, которые нам нужно постоянно загружать.
В npm 7 файл package-lock.json
содержит всё, что нужно npm для полного построения дерева зависимостей проекта. В npm 6 эти данные хранятся не так удобно, поэтому, когда мы сталкиваемся со старым lock-файлом, нам приходится нагружать систему дополнительной работой, но это делается, для одного проекта, лишь один раз.
В результате, даже если в yarn.lock
и были записаны сведения о структуре дерева зависимостей, нам приходится использовать другой файл для хранения дополнительных метаданных.
Будущие возможности
То, о чём мы тут говорили, может серьёзно измениться, если учитывать различные новые подходы к размещению зависимостей на дисках. Это — pnpm, yarn 2/berry и PnP Yarn.
Мы, работая над npm 8, собираемся исследовать подход к формированию деревьев зависимостей, основанный на виртуальной файловой системе. Эта идея смоделирована в Tink, работоспособность концепции подтверждена в 2019 году. Мы, кроме того, обсуждаем идею перехода на что-то вроде структуры, используемой pnpm, хотя это, в некотором смысле, даже более масштабное кардинальное изменение, чем использование виртуальной файловой системы.
Если все зависимости находятся в некоем центральном хранилище, а вложенные зависимости представлены лишь символьными ссылками или виртуальной файловой системой, тогда моделирование структуры дерева зависимостей было бы для нас не таким важным вопросом. Но нам всё ещё нужно больше метаданных, чем способен предоставить файл yarn.lock
. В результате больше смысла имеет обновление и рационализация существующего формата файла package-lock.json
, а не полный переход на yarn.lock
.
Это — не статья, которую можно было бы назвать «О вреде yarn.lock»
Мне хотелось бы особо отметить то, что, судя по тому, что я знаю, Yarn надёжно создаёт корректные деревья зависимостей проектов. И, для определённой версии Yarn (на момент написания материала это относится ко всем свежим версиям Yarn), эти деревья являются, как и при использовании npm, полностью детерминированными.
Файла yarn.lock
достаточно для создания детерминированных деревьев зависимостей с использованием одной и той же версии Yarn. Но мы не можем полагаться на механизмы, зависящие от реализации менеджера пакетов, учитывая использование подобных механизмов во многих инструментах. Это ещё более справедливо, если учесть то, что реализация формата файла yarn.lock
нигде формально не документирована. (Это — не проблема, уникальная для Yarn, в npm сложилась такая же ситуация. Документирование форматов файлов — это довольно серьёзная работа.)
Лучший способ обеспечения надёжности построения строго детерминированных деревьев зависимостей, это, в долгосрочной перспективе, фиксация результатов разрешения зависимостей. При этом не стоит полагаться на веру в то, что будущие реализации менеджера пакетов будут, при разрешении зависимостей, идти тем же путём, что и предыдущие реализации. Подобный подход ограничивает наши возможности по конструированию оптимизированных деревьев зависимостей.
Отклонения от изначально зафиксированной структуры дерева зависимостей должны быть результатом явно выраженного желания пользователя. Такие отклонения должны сами себя документировать, внося изменения в зафиксированные ранее данные о структуре дерева зависимостей.
Только package-lock.json
, или механизм, подобный этому файлу, способен дать npm такие возможности.
Каким менеджером пакетов вы пользуетесь в своих JavaScript-проектах?
Автор: ru_vds