Gerenciar dependências externas com o Bzlmod

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

No Bazel 5.0, o Bzlmod não é ativado por padrão. A flag --experimental_enable_bzlmod precisa ser especificada para que o seguinte tenha efeito. Como o nome da flag sugere, esse recurso está em fase experimental. As APIs e os comportamentos podem mudar até o lançamento oficial do recurso.

Para migrar seu projeto para o Bzlmod, siga o guia de migração do Bzlmod. Também é possível encontrar exemplos de uso 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 repos), criados por regras de repositório (ou regras de repos). 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 publicando 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 do npm, uma caixa do Cargo, um módulo do Go etc.

Um módulo especifica as dependências usando pares name e version, em vez de URLs específicos em WORKSPACE. As dependências são pesquisadas em um registro do Bazel. Por padrão, o Registro central do Bazel. No seu 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. Confira 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 precisa 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 as 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 porque não aceita nenhuma forma de fluxo de controle. Além disso, ele proíbe instruções load. As diretivas compatíveis com 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. A mais popular é o SemVer, mas também há projetos importantes que usam esquemas diferentes, como o Abseil, cujas versões são baseadas em datas, por exemplo, 20210324.2.

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

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

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

Resolução de versão

O problema de dependência de diamante é um elemento básico no espaço de gerenciamento de dependências versionadas. 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 de versão mínima (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, escolhe simplesmente a versão mais alta especificada por qualquer dependente (D 1.1 no nosso exemplo). Ela é chamada de "mínima" porque D 1.1 é a versão mínima que pode atender aos nossos requisitos. Mesmo que D 1.2 ou mais recente exista, não a selecionamos. Isso tem o benefício adicional de que a seleção de versão é de alta fidelidade e reproduzível.

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

Nível de compatibilidade

A proposição do MVS sobre compatibilidade com versões anteriores é viável porque ele trata versões incompatíveis de um módulo como um módulo separado. Em termos de 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ência resolvido. Isso é possível porque a versão principal é codificada no caminho do pacote em Go, então não há conflitos de tempo de compilação ou vinculação.

No Bazel, não temos essas garantias. Assim, 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, podemos gerar um erro quando detectamos que versões do mesmo módulo com diferentes níveis de compatibilidade existem 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 nomes de repositórios diferentes (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 diferentes.

No Bzlmod, os repositórios podem ser gerados por módulos do Bazel e extensões de módulo. Para resolver conflitos de nomes de repositórios, estamos adotando o mecanismo de mapeamento de repositórios no novo sistema. Dois conceitos importantes:

  • Nome canônico do repositório: o nome globalmente exclusivo de cada repositório. Este será o nome do diretório em que o repositório está localizado.
    Ele é construído da seguinte forma (Aviso: o formato de nome canônico não é uma API em que você deve confiar, ele 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ódulo: module_name~version~extension_name~repo_name
      (Exemplo. @rules_cc~0.0.1~cc_configure~local_config_cc)
  • Nome aparente do repositório: 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 forma:

    • 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ódulo: nome do repositório introduzido via use_repo.

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

Dependências estritas

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

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

  • Um repositório de módulo 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 de 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 registros compatível é um registro de índice, que é um diretório local ou um servidor HTTP estático seguindo um formato específico. No futuro, planejamos adicionar suporte a registros de módulo único, que são simplesmente repositórios git que contêm 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. É importante ressaltar que ele não precisa veicular os próprios arquivos de origem.

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 do tipo local_repository no arquivo source.json.
  • /modules: um diretório que contém um subdiretório para cada módulo neste 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 com informações sobre o módulo e os seguintes 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. Isso não é necessariamente o mesmo que os autores do projeto.
      • versions: uma lista de todas as versões do módulo encontradas neste registro.
      • yanked_versions: uma lista de versões removidas deste módulo. No momento, isso não faz nada, mas no futuro, as versões removidas serão ignoradas ou vão gerar um erro.
  • /modules/$MODULE/$VERSION: um diretório que contém os seguintes arquivos:
    • MODULE.bazel: o arquivo MODULE.bazel desta versão do módulo.
    • source.json: um arquivo JSON com informações sobre como buscar a origem desta versão do módulo.
      • O tipo padrão é "archive" com os seguintes campos:
        • url: o URL do arquivo de origem.
        • integrity: o checksum de integridade de subrecursos do arquivo.
        • strip_prefix: um prefixo de diretório a ser removido ao extrair o arquivo de origem.
        • patches: uma lista de strings, cada uma nomeando um arquivo de patch a ser aplicado 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 do Unix.
      • O tipo pode ser mudado para usar um caminho local com estes campos:
        • type: local_path
        • path: o caminho local para o repositório, calculado da seguinte forma:
          • Se o caminho for absoluto, ele será usado como está.
          • Se "path" for um caminho relativo e module_base_path for um caminho absoluto, "path" será resolvido como <module_base_path>/<path>.
          • Se o caminho e module_base_path forem caminhos relativos, o caminho será resolvido como <registry_path>/<module_base_path>/<path>. O registro precisa ser hospedado localmente e usado por --registry=file://<registry_path>. Caso contrário, o Bazel vai gerar um erro.
    • patches/: um diretório opcional que contém arquivos de patch. Só é usado quando source.json tem o tipo "archive".

Registro central do Bazel

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

O BCR é mantido pela comunidade do Bazel. Os colaboradores podem enviar solicitações de pull. 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 exige 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 validade dessa versão do módulo e é usado pelos pipelines de CI do BCR para garantir a interoperabilidade entre módulos no BCR.

Como selecionar registros

A flag repetível do Bazel --registry pode ser usada para especificar a lista de registros de onde solicitar módulos. Assim, é possível configurar o projeto para buscar dependências de um registro interno ou de terceiros. Registros anteriores têm precedência. Para facilitar, você pode colocar uma lista de flags --registry no arquivo .bazelrc do projeto.

Extensões de módulo

As extensões de módulo permitem estender o sistema de módulos lendo dados de entrada de módulos em todo o 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. Elas são semelhantes em função às macros WORKSPACE atuais, mas são mais adequadas no mundo dos módulos e dependências transitivas.

As extensões de módulo são definidas em arquivos .bzl, assim como regras de repositório ou macros WORKSPACE. Elas não são invocadas diretamente. Em vez disso, cada módulo pode especificar partes de dados chamadas de tags para as extensões lerem. Depois que a resolução da versão do módulo é concluída, 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 build 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 exemplo de gráfico de dependência acima, A 1.1, B 1.2 etc. são módulos do Bazel. Cada um pode ser considerado 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 tenha um bazel_dep em D 1.3, mas tenha sido atualizado para D 1.4 devido a C), as extensões "maven" são executadas, e ele lê todas as tags maven.*, usando as informações para decidir quais repositórios criar. O mesmo vale para a extensão "cargo".

Uso de extensões

As extensões são hospedadas nos próprios módulos do Bazel. Portanto, para usar uma extensão no módulo, primeiro adicione um bazel_dep a ele e chame a função use_extension integrada para colocá-la no escopo. Considere o exemplo a seguir, um snippet de um arquivo MODULE.bazel para usar uma extensão hipotética "maven" 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 trazer a extensão para o escopo, use 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 de extensão abaixo. Confira um exemplo que especifica 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 declarar esses repositórios. Isso é para atender à condição de dependências estritas e evitar 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, com base nas tags especificadas, você sabe que a extensão "maven" vai gerar um repositório chamado "org_junit_junit" e outro chamado "com_google_guava_guava". Com use_repo, você pode renomeá-los no escopo do seu módulo, como "guava" aqui.

Definição de extensão

As extensões de módulo são definidas de maneira semelhante às regras de repositório, usando a função module_extension. Ambos têm uma função de implementação, mas 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 tags usadas por esta extensão. Continuando 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 atributo definido acima.

A função de implementação é semelhante a uma macro WORKSPACE, exceto pelo fato de que ela recebe um objeto module_ctx, que concede acesso ao gráfico de dependência e a todas as tags relevantes. A função de implementação precisa chamar as regras do 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, passamos por todos os módulos no gráfico de dependência (ctx.modules), cada um dos quais é um objeto bazel_module cujo campo tags expõe todas as tags maven.* no módulo. Em seguida, invocamos o utilitário de CLI Coursier para entrar em contato com o Maven e realizar a resolução. Por fim, usamos o resultado da resolução para criar vários repositórios usando a regra hipotética maven_single_jar.