Gérer les dépendances externes avec Bzlmod

Bzlmod est le nom de code du nouveau système de dépendance externe introduit dans Bazel 5.0. Il a été introduit pour résoudre plusieurs problèmes de l'ancien système qui ne pouvaient pas être résolus de manière incrémentielle. Pour en savoir plus, consultez la section sur la déclaration de problèmes du document de conception d'origine.

Dans Bazel 5.0, Bzlmod n'est pas activé par défaut. L'option --experimental_enable_bzlmod doit être spécifiée pour que les paramètres suivants prennent effet. Comme son nom l'indique, cette fonctionnalité est actuellement expérimentale. Les API et les comportements peuvent changer jusqu'au lancement officiel de la fonctionnalité.

Modules Bazel

L'ancien système de dépendances externes basé sur WORKSPACE est centré sur les dépôts (ou dépôts), créés via des règles de dépôt (ou règles de dépôt). Bien que les dépôts restent un concept important dans le nouveau système, les modules constituent les unités de dépendance principales.

Un module est essentiellement un projet Bazel pouvant avoir plusieurs versions, chacune publiant des métadonnées sur les autres modules dont elle dépend. C'est semblable aux concepts habituels d'autres systèmes de gestion de dépendances: un artefact Maven, un package npm et un cargo Cargo. , un module Go, etc.

Un module spécifie simplement ses dépendances à l'aide de paires name et version, plutôt que d'URL spécifiques dans WORKSPACE. Les dépendances sont ensuite recherchées dans un registre Bazel. par défaut, le Registre central Bazel. Dans votre espace de travail, chaque module est ensuite transformé en dépôt.

MODULE.bazel

Chaque version de chaque module comporte un fichier MODULE.bazel déclarant ses dépendances et d'autres métadonnées. Voici un exemple de base:

module(
    name = "my-module",
    version = "1.0",
)

bazel_dep(name = "rules_cc", version = "0.0.1")
bazel_dep(name = "protobuf", version = "3.19.0")

Le fichier MODULE.bazel doit se trouver à la racine du répertoire de l'espace de travail (à côté du fichier WORKSPACE). Contrairement au fichier WORKSPACE, vous n'avez pas besoin de spécifier vos dépendances transitives. À la place, vous ne devez spécifier que les dépendances directes, et les fichiers MODULE.bazel de vos dépendances sont traités pour détecter automatiquement les dépendances transitives.

Le fichier MODULE.bazel est semblable aux fichiers BUILD, car il n'est compatible avec aucune forme de flux de contrôle. elle interdit également les instructions load. Les fichiers d'instructions MODULE.bazel sont les suivants:

Format de version

Bazel possède un écosystème diversifié, et les projets utilisent plusieurs schémas de gestion des versions. SemVer est le plus populaire, mais il existe également des projets importants utilisant différents schémas, tels que Abseil, dont les versions sont basé sur la date, par exemple 20210324.2).

Pour cette raison, Bzlmod utilise une version plus souple de la spécification SemVer, en particulier en autorisant un nombre illimité de séquences de chiffres dans la partie "release" de la version (au lieu de trois exactement comme le prévoient SemVer) : MAJOR.MINOR.PATCH). De plus, la sémantique d'augmentation des versions majeures, mineures et correctives n'est pas appliquée. (Toutefois, reportez-vous à la section Niveau de compatibilité pour en savoir plus sur la notation rétrocompatible.) Les autres parties de la spécification SemVer, telles que le trait d'union indiquant une version préliminaire, ne sont pas modifiées.

Résolution de version

Le problème de dépendance Diamant est un élément essentiel de l'espace de gestion des dépendances avec versions gérées. Supposons que vous disposiez du graphe de dépendance suivant:

       A 1.0
      /     \
   B 1.0    C 1.1
     |        |
   D 1.0    D 1.1

Quelle version de D utiliser ? Pour résoudre cette question, Bzlmod utilise l'algorithme de sélection de version minimale (MVS) introduit dans le système de module Go. MVS suppose que toutes les nouvelles versions d'un module sont rétrocompatibles, et sélectionne donc simplement la version la plus élevée spécifiée par n'importe quelle dépendance (D 1.1 dans notre exemple). Elle est appelée "minimale", car D 1.1 est la version minimale qui pourrait répondre à nos exigences. Même si D 1.2 ou une version ultérieure existe, nous ne la sélectionnons pas. Cela présente l'avantage d'avoir une sélection de versions haute fidélité et reproductible.

La résolution de la version est effectuée localement sur votre machine, et non sur le registre.

Niveau de compatibilité

Notez que l'hypothèse de MVS concernant la compatibilité ascendante est réalisable, car elle traite simplement les versions rétrocompatibles d'un module comme un module distinct. En termes de SemVer, cela signifie que A 1.x et A 2.x sont considérés comme des modules distincts et peuvent coexister dans le graphe de dépendance résolu. Cela est rendu possible par le fait que la version majeure est encodée dans le chemin du package dans Go, de sorte qu'il n'y ait aucun conflit au moment de la compilation ou de la liaison.

Dans Bazel, nous n'avons aucune garantie de ce type. Par conséquent, nous avons besoin d'un moyen de spécifier le numéro de "version majeure" afin de détecter les versions incompatibles avec les versions antérieures. Ce numéro, appelé niveau de compatibilité, est spécifié par chaque version de module dans sa directive module(). Avec ces informations en main, nous pouvons générer une erreur lorsque nous détectons que des versions du même module ayant des niveaux de compatibilité différents existent dans le graphe de dépendance résolu.

Noms des dépôts

Dans Bazel, chaque dépendance externe est associée à un nom de dépôt. Parfois, la même dépendance peut être utilisée via différents noms de dépôt (par exemple, @io_bazel_skylib et @bazel_skylib signifient Bazel skylib), ou bien Le nom du dépôt peut être utilisé pour différentes dépendances dans différents projets.

Dans Bzlmod, les dépôts peuvent être générés par les modules Bazel et des extensions de module. Pour résoudre les conflits de noms de dépôts, nous acceptons le mécanisme de mappage des dépôts dans le nouveau système. Il y a deux concepts importants à connaître:

  • Nom de dépôt canonique: nom de dépôt unique pour chaque dépôt. Il s'agit du nom du répertoire dans lequel réside le dépôt.
    Elle est construite comme suit (avertissement: le format du nom canonique n'est pas une API dont vous dépendez, il est susceptible d'être modifié à tout moment):

    • Pour les dépôts de module Bazel: module_name.version
      (Exemple). @bazel_skylib.1.0.3)
    • Pour les dépôts d'extensions de module: module_name.version.extension_name.repo_name
      (Exemple). @rules_cc.0.0.1.cc_configure.local_config_cc)
  • Nom du dépôt local: nom à utiliser dans les fichiers BUILD et .bzl d'un dépôt. La même dépendance peut avoir des noms locaux différents pour différents dépôts.
    Il est déterminé comme suit:

    • Pour les dépôts de module Bazel: module_name par défaut ou le nom spécifié par l'attribut repo_name dans bazel_dep.
    • Pour les dépôts d'extensions de module: nom du dépôt introduit via use_repo.

Chaque dépôt dispose d'un dictionnaire de mappage de ses dépendances directes, qui est un mappage du nom du dépôt local au nom du dépôt canonique. Nous utilisons le mappage du dépôt pour résoudre le nom du dépôt lors de la construction d'un libellé. Notez qu'il n'y a aucun conflit entre les noms de dépôts canoniques et que les utilisations des noms de dépôts locaux sont visibles en analysant le fichier MODULE.bazel. Ainsi, les conflits peuvent être facilement détectés et résolus sans affecter autres dépendances

Directions strictes

Le nouveau format de spécification des dépendances nous permet d'effectuer des vérifications plus strictes. En particulier, nous exigeons désormais qu'un module ne puisse utiliser que des dépôts créés à partir de ses dépendances directes. Cela permet d'éviter les interruptions accidentelles et difficiles à déboguer lorsqu'un élément du graphique des dépendances transitives change.

Les liaisons strictes sont mises en œuvre en fonction du mappage du dépôt. En résumé, le mappage de dépôts pour chaque dépôt contient toutes ses dépendances directes. Les autres dépôts ne sont pas visibles. Les dépendances visibles pour chaque dépôt sont déterminées comme suit:

  • Un dépôt de module Bazel peut afficher tous les dépôts introduits dans le fichier MODULE.bazel via bazel_dep et use_repo.
  • Un dépôt d'extensions de module peut consulter toutes les dépendances visibles du module qui fournit l'extension, ainsi que tous les autres dépôts générés par la même extension de module.

Registres

Bzlmod découvre les dépendances en demandant leurs informations aux registres Bazel. Un registre Bazel est simplement une base de données de modules Bazel. Les seuls types de registres compatibles sont un registre d'index, qui est un répertoire local ou un serveur HTTP statique suivant un format spécifique. Nous prévoyons d'ajouter la compatibilité avec les registres de modules uniques, qui sont simplement des dépôts Git contenant la source et l'historique d'un projet.

Registre d'index

Un registre d'index est un répertoire local ou un serveur HTTP statique contenant des informations sur une liste de modules, y compris leur page d'accueil, leurs responsables, le fichier MODULE.bazel de chaque version et comment récupérer la source de chaque module. version. Plus particulièrement, il n'est pas nécessaire de diffuser les archives sources.

Un registre d'index doit respecter le format ci-dessous:

  • /bazel_registry.json: fichier JSON contenant les métadonnées du registre. Actuellement, il ne possède qu'une seule clé, mirrors, qui spécifie la liste des miroirs à utiliser pour les archives sources.
  • /modules: répertoire contenant un sous-répertoire pour chaque module de ce registre.
  • /modules/$MODULE: répertoire contenant un sous-répertoire pour chaque version de ce module, ainsi que le fichier suivant :
    • metadata.json: fichier JSON contenant des informations sur le module, avec les champs suivants :
      • homepage: URL de la page d'accueil du projet.
      • maintainers: liste d'objets JSON, chacun correspondant aux informations d'un responsable du module dans le registre. Notez que ce n'est pas nécessairement la même que les auteurs du projet.
      • versions: liste de toutes les versions de ce module qui se trouvent dans ce registre.
      • yanked_versions: liste des versions yank de ce module. Il s'agit actuellement d'une opération no-op, mais à l'avenir, les versions suivies seront ignorées ou renverront une erreur.
  • /modules/$MODULE/$VERSION: répertoire contenant les fichiers suivants :
    • MODULE.bazel: fichier MODULE.bazel de cette version de module.
    • source.json: fichier JSON contenant des informations sur la récupération de la source de cette version de module, comportant les champs suivants :
      • url: URL de l'archive source.
      • integrity: somme de contrôle Intégrité des sous-ressources de l'archive.
      • strip_prefix: préfixe de répertoire à supprimer lors de l'extraction de l'archive source.
      • patches: liste de chaînes, chacune nommant un fichier correctif à appliquer à l'archive extraite. Les fichiers correctifs se trouvent dans le répertoire /modules/$MODULE/$VERSION/patches.
      • patch_strip: identique à l'argument --strip du correctif Unix.
    • patches/: répertoire facultatif contenant des fichiers correctifs.

Bazel Central Registry

Bazel Central Registry (BCR) est un registre d'index situé à l'emplacement registry.bazel.build. Son contenu est sauvegardé par le dépôt GitHub bazelbuild/bazel-central-registry.

La BCR est gérée par la communauté Bazel. Les contributeurs sont invités à envoyer des demandes d'extraction. Consultez les Règles et procédures du registre central de Bazel.

En plus de respecter le format d'un registre d'index normal, la BCR requiert un fichier presubmit.yml pour chaque version de module (/modules/$MODULE/$VERSION/presubmit.yml). Ce fichier spécifie quelques cibles de compilation et de test essentielles qui peuvent être utilisées pour vérifier l'intégrité de cette version de module. Il est utilisé par les pipelines CI de la BCR pour assurer l'interopérabilité entre les modules de la BCR. (Installation de Python groupée).

Sélectionner des registres

L'option reproductible Bazel --registry peut être utilisée pour spécifier la liste des registres à partir desquels demander les modules. Vous pouvez donc configurer votre projet pour récupérer les dépendances à partir d'un registre tiers ou interne. Les registres antérieurs sont prioritaires. Pour plus de commodité, vous pouvez placer une liste d'options --registry dans le fichier .bazelrc de votre projet.

Extensions de modules

Les extensions de module vous permettent d'étendre le système de module en lisant les données d'entrée des modules du graphe de dépendances, en exécutant la logique nécessaire pour résoudre les dépendances et en créant des dépôts en appelant les règles du dépôt. Elles sont semblables aux macros WORKSPACE actuelles, mais sont plus adaptées à l'univers des modules et des dépendances transitives.

Les extensions de module sont définies dans des fichiers .bzl, tout comme les règles de dépôt ou les macros WORKSPACE. Ils ne sont pas appelés directement ; En revanche, chaque module peut spécifier des éléments de données appelés tags que les extensions doivent lire. Ensuite, une fois la résolution de version de module terminée, les extensions de module sont exécutées. Chaque extension est exécutée une fois après la résolution du module (avant même qu'une compilation ne se produise) et obtient la lecture de tous les tags lui appartenant sur l'ensemble du graphe de dépendance.

          [ A 1.1                ]
          [   * maven.dep(X 2.1) ]
          [   * maven.pom(...)   ]
              /              \
   bazel_dep /                \ bazel_dep
            /                  \
[ B 1.2                ]     [ C 1.0                ]
[   * maven.dep(X 1.2) ]     [   * maven.dep(X 2.1) ]
[   * maven.dep(Y 1.3) ]     [   * cargo.dep(P 1.1) ]
            \                  /
   bazel_dep \                / bazel_dep
              \              /
          [ D 1.4                ]
          [   * maven.dep(Z 1.4) ]
          [   * cargo.dep(Q 1.1) ]

Dans l'exemple de graphique de dépendances ci-dessus, A 1.1, B 1.2, etc. sont des modules Bazel. Vous pouvez les considérer comme des fichiers MODULE.bazel. Chaque module peut spécifier des tags pour les extensions de module. ici, pour l'extension "maven" et d'autres pour "cargo". Une fois ce graphique de dépendances finalisé (par exemple, B 1.2 a bazel_dep sur D 1.3, mais a été mis à niveau vers D 1.4 en raison de C), la valeur l'extension "maven" est exécutée, et elle peut lire tous les tags maven.*, en utilisant les informations qu'elle contient pour décider des dépôts à créer. Il en va de même pour l'extension "cargo".

Utilisation des extensions

Les extensions sont hébergées dans des modules Bazel. Pour utiliser une extension dans votre module, vous devez donc d'abord ajouter un bazel_dep sur ce module, puis appeler la fonction use_extension intégrée pour la rendre pertinente. Prenons l'exemple suivant, qui présente un extrait d'un fichier MODULE.bazel afin d'utiliser une extension "maven" fictive définie dans le module rules_jvm_external:

bazel_dep(name = "rules_jvm_external", version = "1.0")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")

Après avoir ajouté l'extension au champ d'application, vous pouvez utiliser la syntaxe à points pour spécifier ses tags. Notez que les tags doivent suivre le schéma défini par les classes de tags correspondantes (voir la définition de l'extension ci-dessous). Voici un exemple de balises maven.dep et maven.pom.

maven.dep(coord="org.junit:junit:3.0")
maven.dep(coord="com.google.guava:guava:1.2")
maven.pom(pom_xml="//:pom.xml")

Si l'extension génère des dépôts que vous souhaitez utiliser dans votre module, utilisez la directive use_repo pour les déclarer. Cela permet de satisfaire à la condition stricte deps et d'éviter tout conflit de noms de dépôt local.

use_repo(
    maven,
    "org_junit_junit",
    guava="com_google_guava_guava",
)

Les dépôts générés par une extension font partie de son API. Par conséquent, à partir des tags que vous avez spécifiés, vous devez savoir que l'extension "maven" va générer un dépôt appelé "org_junit_junit" et un "com_google_guava_guava". ". Avec use_repo, vous pouvez éventuellement les renommer dans le champ d'application du module, par exemple "guava" ici.

Définition de l'extension

Les extensions de module sont définies de la même manière que les règles de dépôt, à l'aide de la fonction module_extension. Les deux offrent une fonction de mise en œuvre. Bien que les règles de dépôt possèdent un certain nombre d'attributs, les extensions de module possèdent un certain nombre d'attributs tag_class, chacun d'eux ayant un certain nombre d'attributs. Les classes de tags définissent les schémas des tags utilisés par cette extension. Reprenons notre exemple d'extension "maven" fictif ci-dessus:

# @rules_jvm_external//:extensions.bzl
maven_dep = tag_class(attrs = {"coord": attr.string()})
maven_pom = tag_class(attrs = {"pom_xml": attr.label()})
maven = module_extension(
    implementation=_maven_impl,
    tag_classes={"dep": maven_dep, "pom": maven_pom},
)

Ces déclarations indiquent clairement que les balises maven.dep et maven.pom peuvent être spécifiées à l'aide du schéma d'attribut défini ci-dessus.

La fonction de mise en œuvre est semblable à une macro WORKSPACE, sauf qu'elle obtient un objet module_ctx, qui accorde l'accès au graphe des dépendances et à tous les tags pertinents. La fonction de mise en œuvre doit ensuite appeler les règles du dépôt pour générer des dépôts:

# @rules_jvm_external//:extensions.bzl
load("//:repo_rules.bzl", "maven_single_jar")
def _maven_impl(ctx):
  coords = []
  for mod in ctx.modules:
    coords += [dep.coord for dep in mod.tags.dep]
  output = ctx.execute(["coursier", "resolve", coords])  # hypothetical call
  repo_attrs = process_coursier(output)
  [maven_single_jar(**attrs) for attrs in repo_attrs]

Dans l'exemple ci-dessus, nous passons en revue tous les modules du graphe de dépendance (ctx.modules), chacun d'entre eux étant un objet bazel_module dont le tags. expose toutes les balises maven.* du module. Nous appellerons ensuite coursier, l'utilitaire CLI, pour contacter Maven et effectuer la résolution. Enfin, nous utilisons le résultat de la résolution pour créer un certain nombre de dépôts en utilisant la règle de dépôt maven_single_jar.