Gerenciar dependências externas com o Bzlmod

Bzlmod é o codinome do novo sistema de dependência externa introduzido no Bazel 5.0. Ele foi introduzido para resolver vários aspectos problemáticos do sistema antigo que não podiam ser corrigidos de forma incremental. Consulte a seção "Declaração de problema" do documento de design original para saber mais.

No Bazel 5.0, o Bzlmod não é ativado por padrão. A sinalização --experimental_enable_bzlmod precisa ser especificada para que o código a seguir entre em vigor. Como o nome da flag sugere, esse recurso é experimental. As APIs e os comportamentos podem mudar até que o recurso seja lançado oficialmente.

Para migrar seu projeto para o Bzlmod, siga o Guia de migração do Bzlmod (em inglês). Confira exemplos de usos do Bzlmod no repositório examples.

Módulos do Bazel

O antigo sistema de dependência externa baseado em WORKSPACE é centrado em repositórios (ou repositórios), criados por regras de repositório (ou regras de repo). Embora os repositórios ainda sejam um conceito importante no novo sistema, os módulos são as unidades principais de dependência.

Um módulo é essencialmente um projeto do Bazel que pode ter várias versões. Cada uma publica metadados sobre outros módulos de que depende. Isso é análogo a conceitos conhecidos em outros sistemas de gerenciamento de dependências: um artefato do Maven, um pacote npm, uma caixa Cargo, um módulo do Go etc.

Um módulo simplesmente especifica as próprias dependências usando os pares name e version, em vez de URLs específicos em WORKSPACE. As dependências são pesquisadas em um registro do Bazel (link em inglês). Por padrão, o Registro Central do Bazel (link em inglês). No espaço de trabalho, cada módulo é transformado em um repositório.

MODULE.bazel

Cada versão de cada módulo tem um arquivo MODULE.bazel que declara as dependências e outros metadados. Este é um exemplo básico:

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

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

O arquivo MODULE.bazel deve estar localizado na raiz do diretório do espaço de trabalho (ao lado do arquivo WORKSPACE). Ao contrário do arquivo WORKSPACE, não é necessário especificar as dependências transitivas. Em vez disso, especifique apenas dependências diretas, e os arquivos MODULE.bazel das dependências serão processados para descobrir dependências transitivas automaticamente.

O arquivo MODULE.bazel é semelhante aos arquivos BUILD, já que não oferece suporte a nenhuma forma de fluxo de controle. Além disso, ele proíbe instruções load. As diretivas compatíveis com os arquivos MODULE.bazel são:

Formato da versão

O Bazel tem um ecossistema diversificado e os projetos usam vários esquemas de controle de versões. O SemVer é o mais conhecido, mas também há projetos em destaque que usam esquemas diferentes, como Abseil, com versões baseadas em data. Por exemplo, 20210324.2.

Por esse motivo, o Bzlmod adota uma versão mais flexível da especificação SemVer. As diferenças incluem:

  • O SemVer determina que a parte de "lançamento" da versão precisa consistir em três segmentos: MAJOR.MINOR.PATCH. No Bazel, esse requisito é reduzido para que qualquer número de segmentos seja permitido.
  • No SemVer, cada um dos segmentos na parte "release" deve ter apenas dígitos. No Bazel, isso é reduzido para permitir letras também, e a semântica de comparação corresponde aos "identificadores" na parte "pré-lançamento".
  • Além disso, a semântica de aumentos de versões principais, secundárias e de patch não é aplicada. No entanto, consulte o nível de compatibilidade para ver detalhes sobre como indicamos a compatibilidade com versões anteriores.

Qualquer versão válida do SemVer é uma versão válida do módulo do Bazel. Além disso, duas versões do SemVer a e b comparam a < b se as mesmas ocorrerem quando forem comparadas como versões de módulo do Bazel.

Resolução da versão

O problema de dependência diamante é fundamental no espaço de gerenciamento de dependências com controle de versões. Suponha que você tenha o seguinte gráfico de dependência:

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

Qual versão de D deve ser usada? Para resolver essa questão, o Bzlmod usa o algoritmo de seleção mínima de versão (MVS, na sigla em inglês) introduzido no sistema de módulos Go. O MVS pressupõe que todas as novas versões de um módulo são compatíveis com versões anteriores e, portanto, simplesmente escolhe a maior versão especificada por qualquer dependente (D 1.1 no nosso exemplo). Ela é chamada de "mínima" porque, aqui, D 1.1 é a versão mínima que pode atender aos nossos requisitos. Mesmo que a D 1.2 ou uma versão mais recente exista, não as selecionamos. Isso tem a vantagem adicional de que a seleção da versão é de alta fidelidade e reproduzível.

A resolução da versão é executada localmente na sua máquina, não pelo registro.

Nível de compatibilidade

Observe que a suposição do MVS sobre compatibilidade com versões anteriores é viável porque ele simplesmente trata as versões incompatíveis com versões anteriores de um módulo como um módulo separado. Em termos do SemVer, isso significa que A 1.x e A 2.x são considerados módulos distintos e podem coexistir no gráfico de dependências resolvido. Isso é possível porque a versão principal é codificada no caminho do pacote em Go, de modo que não há conflitos de tempo de compilação ou vinculação.

No Bazel, não temos essas garantias. Portanto, precisamos de uma maneira de indicar o número da "versão principal" para detectar versões incompatíveis com versões anteriores. Esse número é chamado de nível de compatibilidade e é especificado por cada versão do módulo na diretiva module(). Com essas informações em mãos, podemos gerar um erro quando detectarmos que existem versões do mesmo módulo com diferentes níveis de compatibilidade no gráfico de dependência resolvido.

Nomes de repositório

No Bazel, cada dependência externa tem um nome de repositório. Às vezes, a mesma dependência pode ser usada com diferentes nomes de repositório (por exemplo, @io_bazel_skylib e @bazel_skylib significam Bazel skylib) ou o mesmo nome de repositório pode ser usado para dependências diferentes em projetos distintos.

No Bzlmod, os repositórios podem ser gerados por módulos do Bazel e extensões de módulo (link em inglês). Para resolver conflitos de nome de repositório, estamos adotando o mecanismo de mapeamento de repositórios no novo sistema. Aqui estão dois conceitos importantes:

  • Nome do repositório canônico: o nome exclusivo globalmente de cada repositório. Esse vai ser o nome do diretório em que o repositório vai ficar.
    Ele é criado da seguinte maneira. Aviso: o formato de nome canônico não é uma API de que você precisa e está sujeito a mudanças a qualquer momento:

    • Para repositórios de módulos do Bazel: module_name~version
      (exemplo. @bazel_skylib~1.0.3)
    • Para repositórios de extensão de módulos: module_name~version~extension_name~repo_name
      (exemplo. @rules_cc~0.0.1~cc_configure~local_config_cc)
  • Nome do repositório aparente: o nome do repositório a ser usado nos arquivos BUILD e .bzl em um repositório. A mesma dependência pode ter nomes aparentes diferentes em repositórios diferentes.
    Ele é determinado da seguinte maneira:

    • Para repositórios de módulos do Bazel: module_name por padrão ou o nome especificado pelo atributo repo_name em bazel_dep.
    • Para repositórios de extensão de módulos: nome do repositório introduzido usando use_repo.

Cada repositório tem um dicionário de mapeamento de repositórios das dependências diretas, que é um mapa do nome do repositório aparente para o nome canônico do repositório. Usamos o mapeamento do repositório para resolver o nome do repositório ao criar um rótulo. Não há conflito de nomes de repositório canônicos, e os usos de nomes de repositório aparentes podem ser descobertos analisando o arquivo MODULE.bazel. Assim, os conflitos podem ser facilmente detectados e resolvidos sem afetar outras dependências.

Dependências restritas

O novo formato de especificação de dependência permite realizar verificações mais rigorosas. Em particular, agora aplicamos que um módulo só pode usar repositórios criados a partir de dependências diretas. Isso ajuda a evitar interrupções acidentais e difíceis de depurar quando algo no gráfico de dependências transitivas muda.

As dependências restritas são implementadas com base no mapeamento do repositório. Basicamente, o mapeamento de cada repositório contém todas as dependências diretas. Nenhum outro repositório fica visível. As dependências visíveis para cada repositório são determinadas da seguinte maneira:

  • Um repositório de módulos do Bazel pode ver todos os repositórios introduzidos no arquivo MODULE.bazel via bazel_dep e use_repo.
  • Um repositório de extensão de módulo pode ver todas as dependências visíveis do módulo que fornece a extensão, além de todos os outros repositórios gerados pela mesma extensão do módulo.

Registros

O Bzlmod descobre dependências solicitando informações dos registros do Bazel. Um registro do Bazel é simplesmente um banco de dados de módulos do Bazel. A única forma de registro aceita é um registro de índice, que é um diretório local ou um servidor HTTP estático que segue um formato específico. No futuro, planejamos adicionar suporte a registros de módulo único, que são simplesmente repositórios git contendo a origem e o histórico de um projeto.

Registro de índice

Um registro de índice é um diretório local ou um servidor HTTP estático que contém informações sobre uma lista de módulos, incluindo a página inicial, os mantenedores, o arquivo MODULE.bazel de cada versão e como buscar a origem de cada versão. Ele não precisa disponibilizar os arquivos de origem por si só.

Um registro de índice precisa seguir o formato abaixo:

  • /bazel_registry.json: um arquivo JSON que contém metadados para o registro, como:
    • mirrors, especificando a lista de espelhos a serem usados para arquivos de origem.
    • module_base_path, especificando o caminho base para módulos com o tipo local_repository no arquivo source.json.
  • /modules: um diretório que contém um subdiretório para cada módulo nesse registro.
  • /modules/$MODULE: um diretório que contém um subdiretório para cada versão deste módulo, além do seguinte arquivo:
    • metadata.json: um arquivo JSON que contém informações sobre o módulo, com estes campos:
      • homepage: o URL da página inicial do projeto.
      • maintainers: uma lista de objetos JSON, cada um correspondendo às informações de um mantenedor do módulo no registro. Observe que não é necessariamente igual aos authors do projeto.
      • versions: uma lista de todas as versões do módulo disponíveis nesse registro.
      • yanked_versions: uma lista de versões yanked deste módulo. Atualmente, é um ambiente autônomo, mas no futuro, as versões puxadas serão ignoradas ou gerarão um erro.
  • /modules/$MODULE/$VERSION: um diretório que contém os seguintes arquivos:
    • MODULE.bazel: o arquivo MODULE.bazel da versão do módulo.
    • source.json: um arquivo JSON com informações sobre como buscar a origem dessa versão do módulo.
      • O tipo padrão é "arquivo" com os seguintes campos:
        • url: o URL do arquivo de origem.
        • integrity: a soma de verificação de integridade de sub-recursos do arquivo.
        • strip_prefix: um prefixo de diretório para remover ao extrair o arquivo de origem.
        • patches: uma lista de strings. Cada uma delas nomeia um arquivo de patch para aplicar ao arquivo extraído. Os arquivos de patch estão localizados no diretório /modules/$MODULE/$VERSION/patches.
        • patch_strip: igual ao argumento --strip do patch Unix.
      • O tipo pode ser alterado para usar um caminho local com estes campos:
        • type: local_path
        • path: o caminho local para o repositório, calculado da seguinte maneira:
          • Se o caminho for absoluto, será usado como está.
          • Se o caminho for relativo e module_base_path for absoluto, o caminho será resolvido para <module_base_path>/<path>
          • Se o caminho e module_base_path forem relativos, o caminho será resolvido para <registry_path>/<module_base_path>/<path>. O registro precisa ser hospedado localmente e usado por --registry=file://<registry_path>. Caso contrário, o Bazel gera um erro.
    • patches/: um diretório opcional contendo arquivos de patch, usado apenas quando source.json tem o tipo "arquivo".

Registro central do Bazel

O Bazel Central Registry (BCR) é um registro de índice localizado em bcr.bazel.build. O conteúdo é respaldado pelo repositório do GitHub bazelbuild/bazel-central-registry (em inglês).

O BCR é mantido pela comunidade do Bazel. Os colaboradores podem enviar solicitações de envio. Consulte as Políticas e procedimentos do Registro Central do Bazel.

Além de seguir o formato de um registro de índice normal, o BCR requer um arquivo presubmit.yml para cada versão do módulo (/modules/$MODULE/$VERSION/presubmit.yml). Esse arquivo especifica alguns destinos essenciais de build e teste que podem ser usados para verificar a integridade da versão do módulo. Ele é usado pelos pipelines de CI do BCR para garantir a interoperabilidade entre módulos no BCR.

Como selecionar registros

A sinalização repetível do Bazel --registry pode ser usada para especificar a lista de registros para solicitar módulos. Assim, você pode configurar seu projeto para buscar dependências de um registro interno ou externo. Registros anteriores têm prioridade. Por conveniência, coloque uma lista de sinalizações --registry no arquivo .bazelrc do seu projeto.

Extensões do módulo

As extensões de módulo permitem estender o sistema de módulos lendo dados de entrada de módulos no gráfico de dependências, executando a lógica necessária para resolver dependências e, por fim, criando repositórios chamando regras de repositório. A função delas é semelhante às macros WORKSPACE atuais, mas são mais adequadas no mundo dos módulos e das dependências transitivas.

As extensões do módulo são definidas em arquivos .bzl, assim como as regras de repositório ou macros WORKSPACE. Eles não são invocados diretamente. Em vez disso, cada módulo pode especificar dados, chamados de tags, para leitura das extensões. Em seguida, após a conclusão da resolução da versão do módulo, as extensões do módulo são executadas. Cada extensão é executada uma vez após a resolução do módulo (ainda antes de qualquer criação acontecer) e lê todas as tags pertencentes a ela em todo o gráfico de dependência.

          [ 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) ]

No gráfico de dependências de exemplo acima, A 1.1 e B 1.2 são módulos do Bazel. Pense em cada um como um arquivo MODULE.bazel. Cada módulo pode especificar algumas tags para extensões de módulo. Aqui, algumas são especificadas para a extensão "maven" e outras para "cargo". Quando esse gráfico de dependência é finalizado (por exemplo, talvez B 1.2 realmente tenha um bazel_dep em D 1.3, mas tenha sido atualizado para D 1.4 devido a C), o "maven" de extensões é executado e consegue ler todas as tags maven.*, usando as informações contidas nele para decidir quais repositórios criar. O mesmo acontece com a extensão "cargo".

Uso da extensão

As extensões são hospedadas nos próprios módulos do Bazel. Portanto, para usar uma extensão no seu módulo, você precisa primeiro adicionar um bazel_dep no módulo e, em seguida, chamar a função integrada use_extension (link em inglês) para incluí-la no escopo. Considere o exemplo a seguir, um snippet de um arquivo MODULE.bazel para usar uma extensão "maven" hipotética definida no módulo rules_jvm_external:

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

Depois de colocar a extensão no escopo, você pode usar a sintaxe de ponto para especificar tags para ela. As tags precisam seguir o esquema definido pelas classes de tag correspondentes. Consulte a definição da extensão abaixo. Veja um exemplo especificando algumas tags maven.dep e 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")

Se a extensão gerar repositórios que você quer usar no seu módulo, use a diretiva use_repo para declará-los. Isso atende à condição de dependências rígidas e evita conflitos de nomes de repositórios locais.

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

Os repositórios gerados por uma extensão fazem parte da API dela. Portanto, a partir das tags especificadas, a extensão "maven" vai gerar um repositório chamado "org_junit_junit" e outro chamado "com_google_guava_guava". Com use_repo, você tem a opção de renomeá-los no escopo do módulo, como "guava".

Definição de extensão

As extensões do módulo são definidas de maneira semelhante às regras de repositório, usando a função module_extension. Ambas têm uma função de implementação. No entanto, enquanto as regras de repositório têm vários atributos, as extensões de módulo têm vários tag_classes, cada um com vários atributos. As classes de tag definem esquemas para as tags usadas por essa extensão. Continuação com nosso exemplo da extensão hipotética "maven" acima:

# @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},
)

Essas declarações deixam claro que as tags maven.dep e maven.pom podem ser especificadas usando o esquema de atributos definido acima.

A função de implementação é semelhante a uma macro WORKSPACE, exceto pelo fato de receber um objeto module_ctx, que concede acesso ao gráfico de dependência e a todas as tags pertinentes. A função de implementação precisa chamar regras de repositório para gerar repositórios:

# @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]

No exemplo acima, percorremos todos os módulos no gráfico de dependências (ctx.modules), cada um deles um objeto bazel_module cujo campo tags expõe todas as tags maven.* no módulo. Em seguida, invocamos o utilitário da CLI Coursier para entrar em contato com o Maven e realizar a resolução. Por fim, usamos o resultado da resolução para criar uma série de repositórios com a regra hipotética de repo maven_single_jar.