Quando eu vi que o time do ASP.NET havia incluído um arquivo de lock no DNX eu gostei bastante. Eu gosto da ideia, que vem do Ruby com o Gemfile.lock e do Node com o npm-shrinkwrap.json (e provavelmente de antes disso com alguma linguagem obscura). Logo associei que o project.lock.json existia pelo exato mesmo motivo que esses arquivos. Mas eu estava errado. Na verdade, parcialmente errado. Vou explicar aqui pra que esses arquivos de Ruby e Node servem, e o que o project.lock.json faz que é igual (e o que não é). Outro ponto interessante que vou abordar: você deve ou não versioná-lo no seu source control?

Pra adiantar:

  • No Twitter David Fowler, um dos desenvolvedores que lidera o novo ASP.NET diz que temos sim momentos para versionar, e o Damian Edwards especifica: quando você taggeia uma release, para garantir que consegue refazer o build depois.
  • Enquanto isso o Github já colocou o project.lock.json na recomendação dele de .gitignore. Vejam aqui on repositólrio gitignore deles (muito útil, por sinal).
  • Há uma issue aberta no Github do ASP.NET pra excluir todos os project.lock.json dos repositórios do ASP.NET.

Vamos entender primeiro como gerenciamos as dependências.

Versões de dependências e seu intervalos

Um arquivo de lock existe para resolver um problema que não deveria existir, ou que ao menos deveria estar sob controle. Ele é diretamente ligado ao fato de que especificamos dependências como faixas de valores. A maneira com que o Nuget especifica essas faixas é parecida com a que o Maven, do Java, especifica, e você pode ler mais sobre ela nos docs do Nuget. Imagino que muita gente nem saiba que esses ranges existem, no .NET é menos comum usar isso. Ou era, até o DNX surgir. Simplificando um pouco, funciona assim:

  • Um valor simples, por exemplo “2.0”, significa instale qualquer coisa maior ou igual a “2.0”.
  • “” significa qualquer versão.
  • Parêntesis significam até aquele valor, mas com ele excluído, então (1.0, 2.0) significa qualquer coisa entre 1.0 e 2.0, mas não exatamente nenhum desses valores.
  • Colchetes significam até aquele valor, com ele incluído, então [1.0, 2.0] significa qualquer coisa entre 1.0 e 2.0, incluindo esses valores.
  • Uma versão exata se especifica com dois colchetes, por exemplo: [3.2.0].
  • Você pode deixar um número do range livre, por exemplo, (1.0,) significa qualquer coisa maior que 1.0, mas não inclui 1.0. Se quiser qualquer valor até 1.0, com ele incluído, fica [,1.0].
  • Você pode usar 3 números, seguindo o semver. O nuget permite 4, mas não use a quarta porque desrespeita o semver (e causa bugs).
  • Um número deixado em branco significa que vale o maior, assim [3.2] vai aceitar 3.2.1, 3.2.2, etc.

O problema

O que acontece é que normalmente nós especificamos sempre valores simples, imaginando que eles são exatos. Assim, se você especifica “2.0”, é o mesmo que dizer “[2.0,]”, ou seja, qualquer coisa maior que isso poderá ser instalada. Isso vira um problema ainda maior quando pensamos que os arquivos que especificam os ranges (package.config antes do DNX, e agora o project.json) vão para o source control. Assim, quando você clona o projeto novamente e faz uma restauração dos pacotes, ou um colega seu ganha uma máquina nova e clona pela primeira vez, ou durante um build, a versão do pacote que virá não é necessariamente a que você especificou.

Quando uma versão diferente da que você especificou é instalada, o código pode quebrar. No entanto, isso nunca deveria acontecer se quem escreve bibliotecas respeitasse o semver. Segundo o semver, deveria ser seguro você atualizar qualquer pacote, desde que a versão Major não seja quebrada. Assim, atualizar um pacote da versão 1.2.3 para 1.2.6 ou 1.4.5 não deveria, jamais, criar um bug, falhar uma build, etc. No entanto, isso acontece. Os projetos não respeitam o semver o tempo todo.

Por esse motivo precisamos ser capazes de dizer exatamente a versão que queremos. Isso pode ser feito, conforme dito, usando dois colchetes. Mas também queremos atualizar nossos pacotes, e se fixarmos a versão de uma dependência, eliminando o range, o DNU (.NET Utilities, que cumpre boa parte das funções do Nuget no DNX) nunca vai  atualizar um pacote, e podemos perder atualizações importantes, como correções de bugs e segurança.

O arquivo de lock

Restaurando (clique para ampliar e ver a restauração acontecendo):

dnu restore acontecendo (gif animado)

A solução para esse problema é o arquivo de “lock”, que é o foco desse post. O arquivo de lock do DNX contém a informações sobre quais target runtimes sua aplicação/pacote suporta e de quais pacotes sua aplicação depende (para cada target), quais estavam sendo utilizados quando ele foi gerado, e informações detalhadas sobre cada uma dessas dependências, incluindo um hash de segurança baseado em SHA512 e uma lista dos arquivos do pacote. O arquivo de lock não contém somente as dependências diretas, ele vai além e lista toda a árvore de dependências, entrando recursivamente em cada pacote e listando as dependências dele. Ele normalmente fica bastante extenso (veja um exemplo aqui).

Nota: em um projeto que não é DNX a versão exata a ser utilizada durante a compilação fica armazenada no arquivo .csproj (ou .vbproj), e depois na dll gerada.

O arquivo de lock normalmente é utilizado para que, sempre que você fizer um restore dos seus pacotes, as mesmas versões listadas nele são instaladas. Esse é o principal papel do arquivo de lock no Node e no Ruby. E, apesar de ele fazer isso também no DNX, essa funcionalidade fica um pouco escondida e desligada por padrão. No DNX, sempre que você faz um restore o arquivo de lock é ignorado e um novo é criado. Para que ele funcione da mesma forma que o arquivo de shrinkwrap do Node, ou seja, sempre que houver um restore as dependências instaladas são as listadas nele, não as do project.json, você deve trancar o arquivo de lock. Em português isso não soa tão ruim, mas é uma redundância, basicamente você “lock the lockfile”. Oras, se o arquivo serve pra trancar, porque você precisa trancar? Explico em um minuto, mas primeiro vamos ver como trancá-lo.

Trancando o project.lock.json

Pra trancar o arquivo de lock rode:

dnu restore --lock

Com isso o arquivo será alterado e o atributo “locked” dentro dele passará para “true”. A partir de então a versão a ser restaurada será sempre a que está listada nele. Subsequentes execuções de “dnu restore” não irão atualizar nenhuma dependência, nem de segurança, nem de bugs, nenhuma. E você verá a mensagem “Following lock file C:\proj\project.lock.json” após restaurar.

Para destrancar o arquivo basta rodar (adivinhe?):

dnu restore --unlock

E o atributo “lock” volta para o valor “false”, e o comportamento padrão volta a valer.

A pergunta que fica é: pra que serve o arquivo de lock se ele não é usado para trancar as dependências? Pra rodar a aplicação, oras. Hein?

Arquivo project.lock.json e a execução de uma app DNX

Um arquivo de lock é gerado sob demanda, geralmente após uma instalação ou restauração de dependência, ou diretamente. No node você roda “npm shrinkwrap”, por exemplo, e ele gera o npm-shrinkwrap.json. No DNX, é sempre que você instala, atualiza ou desinstala um pacote, restaura ou publica um projeto, de forma automática.

Na hora de executar a aplicação o arquivo de lock é utilizado. Como as dependências de DNX são instaladas de forma global por usuário (em %USERPROFILE%\.dnx\packages), e permitem várias versões instaladas lado a lado, não basta informar que a aplicação depende da dependência X se há 3 versões de X instaladas, 1.0.0, 1.1.0, e 2.0.0. Digamos que a app dependa de “1.0.0”, isso significa que a versão 2.0.0 poderá utilizada. Mas deverá? E se houver outra dependência no projeto que também dependa de X, mas a especificação de versão dessa subdependência pra X seja de “[1,]”. Quer dizer que não podemos rodar a versão 2.0.0, o ideal seria a versão 1.1.0. Se essa resolução de dependência tiver que ser feita sempre que a aplicação for rodar teremos um impacto em desempenho. Por esse motivo ela feita de forma adiantada, e armazenada no arquivo de lock. Para subir a app basta ler o arquivo de lock e carregar todas as dependências presentes nele, iniciando então pelo entry point definido (método Main ou seguindo o entry point definido via elemento script no project.json). Muito mais eficiente.

Tente restaurar um projeto ASP.NET 5 e então apagar o arquivo project.lock.json, ele irá falhar e você verá a mensagem “Try running ‘dnu restore’.”. Mesmo com as dependências instaladas você precisa do arquivo de lock presente para rodar a app, é ele que vai indicar o que deve ser executado de fato. Uma maneira de olhar para o arquivo de lock é como um filtro que será aplicado ao diretório global de pacotes para que ele fique parecendo só conter o que a app precisa.

Versionar ou não versionar?

Essa é a grande questão.

A resposta curta é: não, não versione. Coloque “project.lock.json”, exatamente assim, no seu .gitignore, e qualquer arquivo com esse nome será ignorado.

Reposta longa:

Você não quer, durante o desenvolvimento, trancar a versão das suas dependências. Se um build ou teste falhar porque uma dependência atualizou, durante desenvolvimento, você quer saber. Então não guarde o arquivo de lock no seu source control em um projeto DNX. Mas faz todo sentido que você versione o arquivo de lock sempre que fizer uma release. Ou seja, chegou no ponto de fazer release, vá até o commit, faça um restore com lock rodando “dnu restore –lock”, faça uma checkout do SHA daquele release com “git checkout <sha>”, adicione os arquivos de lock de todos os seus projetos ao projeto com “git add -f project.lock.json” e faça um commit, e logo em seguida uma tag. Esse é o commit da sua versão de release. Não esqueça da tag, ou você irá perder esse commit assim que voltar pra qualquer outro branch, e empurre as tags com “git push –tags”. Isso vai garantir que você sempre vai conseguir reconstruir a aplicação exatamente como era quando executou agora.

Resumindo

Versões só são atualizadas no seu projeto quando você roda “dnu restore”, não há o risco de uma versão mais nova ficar disponível pra aplicação (no cache do usuário, por exemplo) e ser executada.

O arquivo de lock só “locka” as dependências durante runtime, nunca em restore, a não ser que você rode “dnu restore –lock”.

Não coloque os arquivos project.lock.json no repositório do seu projeto, adicione uma exclusão deles no .gitignore, mas coloque-os durante as releases (e com lock).

Giovanni Bassi

Arquiteto e desenvolvedor, agilista, escalador, provocador. É fundador e CSA da Lambda3. Programa porque gosta. Acredita que pessoas autogerenciadas funcionam melhor e por acreditar que heterarquia é mais eficiente que hierarquia. Foi reconhecido Microsoft MVP há mais de dez anos, dos mais de vinte que atua no mercado. Já palestrou sobre .NET, Rust, microsserviços, JavaScript, TypeScript, Ruby, Node.js, Frontend e Backend, Agile, etc, no Brasil, e no exterior. Liderou grupos de usuários em assuntos como arquitetura de software, Docker, e .NET.