De .NET Framework 4.8 a .NET 8, decisão por decisão
Um back office de fretes construído em 2011: MVC 5, umWeb.config de 304 linhas, uma action de controller com 272 linhas e quatro dependências sem sucessor. Estrangulado uma rota por vez, com as falhas mantidas no registro.
- .NET 8
- EF Core
- YARP
- Strangler fig
A aplicação que herdamos
O que a Meridian Despatch realmente era, medida em vez de caracterizada, antes de qualquer coisa se mover.
A Meridian Despatch registra fretes. Um atendente cadastra uma coleta, o sistema precifica, uma etiqueta é impressa num depósito, um manifesto sai durante a noite e uma fatura vem no fim do mês. Está em serviço desde 2011 e funciona, que é a primeira coisa que vale dizer sobre ela: ninguém está migrando isto porque está quebrado.
Antes de descrevê-la, aqui está o que ela mede. Adjetivos sobre código legado são baratos; os números não são.
- C#
- 1.331 linhas em 20 arquivos
- Web.config
- 304 linhas
- Binding redirects
- 18
- Chaves em appSettings
- 22, das quais 12 são regras de negócio
- Pacotes NuGet
- 22, via packages.config
- Entradas Compile
- 18, mantidas à mão no .csproj
- Método mais longo
- ConsignmentsController.Create: 272 linhas
- Testes
- 0
O método que é a migração
ConsignmentsController.Create tem 272 linhas. Numa única action ele deriva o
peso taxável, descobre a zona de entrega a partir de uma cadeia de prefixos de
CEP, abre uma SqlConnection para consultar uma faixa tarifária, aplica
adicionais de combustível, área remota, entrega agendada e carga perigosa, aplica
um desconto contratual que se comporta de forma diferente para três clientes,
calcula o imposto, roda uma checagem de duplicidade concatenando os valores do
formulário num SELECT COUNT(*), salva via EF6, envia uma string de largura fixa
para o MSMQ, chama um serviço SOAP e escreve duas linhas de auditoria.
Não existe camada de serviço para onde movê-lo. Não existe costura. Não é tanto mal escrito quanto indistinto: nove anos de pequenas decisões corretas, cada uma fazendo sentido no dia, nenhuma delas jamais consolidada.
Esse método é a migração. Todo o resto é preparação para conseguir tocá-lo.
Quatro dependências sem sucessor
Esta é a parte que decide se uma migração é um projeto ou uma aventura. Diferenças de framework e de linguagem são trabalho; uma dependência que simplesmente não existe do outro lado é uma decisão.
- System.Messaging
- MSMQ, a entrega para o depósito. Nunca foi portado para o .NET Core e nunca será. Sem shim, sem pacote de compatibilidade.
- Microsoft.ReportViewer
- Vinte relatórios RDLC escritos no designer de relatórios do Visual Studio entre 2011 e 2017. O formato não tem outro renderizador.
- MachineKey
- A autenticação por formulário criptografa o cookie dela com isso. O ASP.NET Core tem Data Protection no lugar, que é um algoritmo diferente.
- System.Drawing
- Etiquetas de despacho, incluindo um código de barras desenhado à mão a partir de uma tabela Code 39 porque o componente licenciado se perdeu em 2012.
Dois defeitos deixados de propósito
A caixa de busca concatena o conteúdo dela numa cláusula WHERE. Foi reportado
internamente em 2019 como MER-2213 e continua aberto. A alocação de referência é
MAX(...) + 1 sem lock nenhum, então dois atendentes registrando no mesmo
segundo colidem e o segundo recebe uma tela amarela.
Os dois foram deixados exatamente como encontrados neste primeiro commit. Corrigi-los aqui significaria que a linha de base deixou de ser a coisa que está sendo migrada, e o harness de paridade que chega no estágio 03 estaria comparando o código novo contra um oráculo que ninguém nunca rodou em produção.
Eles são corrigidos depois, nos estágios em que aquele código estava sendo reescrito de qualquer jeito. Isso não é uma virtude: é como tickets abertos há muito tempo de fato são fechados.
O que isto é, e o que não é
Este é um sistema construído para o propósito. O código de um ex-cliente não é meu para publicar, então a Meridian foi escrita para carregar as patologias específicas que tornam essas migrações caras, e depois foi genuinamente migrada.
As armadilhas são reais. System.Messaging realmente não tem sucessor.
MachineKey realmente não pode ser lido pelo Data Protection. O EF Core
realmente assume (18,2) para decimal onde o EF6 tinha sido instruído a usar
(18,4), e realmente faz isso em silêncio. Cada uma dessas coisas está escrita
abaixo porque mordeu esta base de código, neste repositório, com um commit que a
corrigiu.
O que não é alegado em lugar nenhum deste diário é um incidente de produção. Nada aqui diz que um cliente ligou. Onde um estágio dá um custo, é o custo de encontrar aquilo aqui.
Um gateway na frente de tudo, roteando nada
Um proxy reverso entrou primeiro, mandando 100% do tráfego para a aplicação que ele deveria substituir.
O primeiro commit da migração não migra nada. Ele coloca o YARP na frente da aplicação com uma única rota catch-all apontando para a mesma máquina IIS que já servia todas as requisições.
Nenhum usuário viu diferença. Nenhum código se moveu. É uma semana difícil de justificar numa daily, e é o primeiro movimento certo.
- Navegadorcliente
- IISmeridian-app01
- Meridian.Web.NET 4.8
- SQL Serverbanco de dados
- Navegadorcliente
- GatewayYARP · .NET 8
- IISmeridian-app01
- Meridian.Web.NET 4.8
- SQL Serverbanco de dados
Uma caixa nova, e toda rota ainda termina no mesmo lugar. A única coisa que mudou é que agora existe onde colocar uma decisão.
Por que isto é o primeiro e não o terceiro
O padrão strangler é uma frase: mova uma rota por vez para a aplicação nova. Essa frase exige algo que seja dono da tabela de roteamento.
Sem isso, o primeiro módulo a se mover tem que se mover por DNS, por uma regra de rewrite do IIS, ou por uma janela de deploy, e cada um desses move tudo de uma vez para alguma classe de usuário. Uma migração que não consegue direcionar tráfego por rota não tem estágios. Tem um cutover, fantasiado.
Fazer isso primeiro tem um segundo benefício que importa mais na prática. É a mudança de infraestrutura mais arriscada do projeto inteiro (um salto novo em toda requisição, com timeouts, tratamento de headers e pool de conexões próprios), e ela chega enquanto exatamente nada depende dela. Se o proxy vai estragar alguma coisa, a semana de descobrir é a semana em que reverter significa apagar um contêiner.
A tabela de rotas é a barra de progresso
O mesmo arquivo, oito estágios depois. Toda rota que nomeia o cluster moderno é um pedaço da aplicação que se moveu; o catch-all na ordem 100 é tudo o que não se moveu.
"Routes": {
"ratecards-new": {
"ClusterId": "modern",
"Match": { "Path": "/ratecards/{**catch-all}" },
"Order": 1
},
"consignments-api-new": {
"ClusterId": "modern",
"Match": {
"Path": "/consignments/{**catch-all}",
"Headers": [ { "Name": "Accept", "Values": [ "application/json" ] } ]
},
"Order": 2
},
"everything-legacy": {
"everything-else-legacy": {
"ClusterId": "legacy",
"Match": { "Path": "{**catch-all}" },
"Order": 100
}
}
Números de ordem baixos são as rotas migradas, então as novas são adicionadas no topo e o catch-all nunca precisa se mexer. Ler este arquivo de cima para baixo é ler a migração em ordem.
O que quebrou
- Sintoma
- Faça login, envie qualquer formulário, e o navegador cai numa URL contendo a porta interna (meridian-app01:52341), inalcançável de fora. Todo GET funcionava.
- Causa
- A Meridian.Web monta URLs absolutas a partir de Request.Url. Atrás de um proxy, esse é o endereço do salto interno, não o endereço que o cliente pediu. Só o redirect-depois-do-POST expõe isso, porque só redirects colocam uma URL absoluta montada pelo servidor num header Location.
- Pego por
- Na mão, usando a aplicação. Nenhum teste cobria um destino de redirect, e nenhuma linha de log estava errada: a aplicação se comportava exatamente como escrita.
O instinto foi que um proxy novo tinha quebrado alguma coisa, então dois dias
foram para o encaminhamento de headers do YARP antes de alguém ler Request.Url.
O proxy esteve correto o tempo todo. A aplicação sempre esteve errada sobre o
próprio endereço; nunca ninguém tinha perguntado a ela.
A correção do lado 4.8 é uma URL base ciente de X-Forwarded-*. Do lado .NET 8 é
UseForwardedHeaders, que está no commit do estágio 04 e não custou nada, porque
a essa altura era um problema conhecido em vez de descoberto.
Custo
Toda requisição agora tem um salto de rede a mais, um conjunto a mais de timeouts para ajustar, e um segundo lugar para olhar quando algo está lento. O gateway também vira um ponto único de falha para uma aplicação que antes tinha um a menos.
Comprou
O tráfego pode ser direcionado por rota. Todo estágio posterior é uma mudança de configuração em um arquivo, reversível em segundos, em vez de uma janela de deploy.
Uma transcrição da aritmética antiga, mantida de propósito
A lógica de preço do 4.8, copiada sem melhorias para dentro de uma fixture de teste, para que todo estágio posterior tenha contra o que estar errado.
A linha de base tinha zero testes. Isso é normal e é justamente o problema: não existe definição de comportamento correto em lugar nenhum, exceto o próprio comportamento.
Então a definição é escrita. LegacyRating.cs é uma transcrição da aritmética
que está dentro de ConsignmentsController.Create, com o banco, o HttpContext
e o MSMQ recortados fora e nada mais tocado. Mesmo aninhamento, mesma ordem,
mesmos dois pontos separados de arredondamento.
Não é uma versão mais arrumada. O valor inteiro dela é não ser uma melhoria.
Varrer, não amostrar
Quatro mil entradas geradas, comparadas campo a campo, falhando por um centavo. A semente é fixa: um harness que gera entradas diferentes a cada execução não consegue dizer se você consertou alguma coisa.
Exemplos escolhidos a dedo teriam perdido as três divergências interessantes encontradas até agora, porque cada uma delas estava numa fronteira para a qual ninguém pensaria em escrever um teste.
- Um CEP de Dublin começando com PA
- O país tem que ganhar do prefixo do CEP. No original ele ganhava, mas só porque a checagem de país rodava por último e sobrescrevia o que as checagens de prefixo já tinham definido. Reescrito como uma tabela de consulta, a leitura natural inverte isso e Dublin é precificada como Terras Altas da Escócia.
- Exatamente 10kg numa tabela cujas faixas se encontram em 10
- A aplicação antiga tinha duas consultas de tarifa que discordavam na fronteira: SQL inline com BETWEEN na tela de reserva, uma consulta LINQ na tela da tabela de tarifas. Nove anos, ninguém sabia.
- Um líquido que arredonda para cima enquanto o imposto dele arredonda para baixo
- O assunto do segundo commit deste estágio, abaixo.
O commit que corrigiu um bug que os testes não conseguiam ver
O engine tirava o imposto do líquido já arredondado. O controller 4.8 tira o imposto do líquido não arredondado e arredonda o líquido separadamente, na linha seguinte.
Todos os quatro mil casos de paridade passavam dos dois jeitos.
Duas linhas trocadas, nenhuma diferença observável em todo o corpus, e digno de um commit próprio.
var roundedNet = Round(net);
var vat = Round(roundedNet * vatRate);
var vat = Round(net * vatRate);
var roundedNet = Round(net);
Passar não é equivalência. Distinguir esses dois exige um líquido cuja terceira casa decimal leve o imposto dele através de uma fronteira de meio centavo, e um corpus que nunca produz um não provou nada: ele falhou em refutar.
Fui procurar uma entrada divergente com uma varredura direcionada de quatro milhões de valores e também não encontrei. Isso ainda não é equivalência; é uma busca mais estreita com o mesmo formato de resposta.
- Sintoma
- Nada. Todo teste passou, e uma varredura de quatro milhões de valores atrás de um contraexemplo também voltou vazia.
- Causa
- O engine e o original tiram o imposto de quantidades diferentes: um arredonda antes de multiplicar, o outro depois. A diferença é estrutural e o corpus de teste simplesmente nunca produziu uma entrada que a expusesse.
- Pego por
- Lendo as duas implementações lado a lado enquanto escrevia esta entrada. Nenhum teste teria pego.
A correção é acompanhar a estrutura do original em vez da saída observada dele. Custo de fazer isso: nada. Custo de estar errado: um centavo sistemático em 41.000 carregamentos, e uma conversa com o financeiro, que reconcilia os totais novos contra os antigos.
Isso virou a regra para o resto da migração. Onde o formato do código antigo é arbitrário, melhore. Onde o formato é estrutural e o corpus de teste não consegue distinguir, copie o formato.
O teste que estava errado
Um teste afirmava que meio quilo é cobrado como meio quilo. Ele falha: meio quilo
é cobrado como um, porque MinimumChargeableWeightKg se aplica antes do
arredondamento.
O engine estava certo e o teste estava errado. Ele foi mantido, com a expectativa corrigida e um comentário explicando a armadilha, porque a próxima pessoa que ler a fórmula volumétrica vai fazer exatamente a mesma suposição.
Custo
Um arquivo no projeto de testes que é uma cópia deliberada de código que está sendo apagado, que todo revisor aponta como duplicação, e que tem que ser mantido se as regras antigas mudarem durante a migração.
Comprou
Todo estágio posterior pode ser checado contra alguma coisa em vez de revisado por plausibilidade. Dois dos três defeitos acima teriam ido para produção.
LegacyRating.cs é apagado no dia em que a aplicação 4.8 for desligada, e nem um
commit antes.
O cookie que precisava ser legível pelas duas stacks
A autenticação por formulário criptografa com MachineKey e o ASP.NET Core não consegue ler isso. Nada podia subir até resolver.
Este é o estágio que decide se uma migração strangler é possível, e é por isso que ele vem antes de qualquer módulo se mover.
A autenticação por formulário criptografa o ticket dela com MachineKey. O
ASP.NET Core não tem MachineKey: ele tem Data Protection, um algoritmo
diferente, com derivação de chave diferente e formato de payload diferente. Sem
configuração, as duas stacks não conseguem ler o cookie uma da outra.
A consequência não é sutil. Um usuário segue um link de uma página servida pela aplicação antiga para uma página servida pela nova e simplesmente é deslogado. Toda rota que se move leva a sessão do usuário junto.
- Meridian.Web.NET 4.8
- Ticket FormsAuthMachineKey
- .MERIDIANAUTHcookie
- Meridian.Web.NET 4.8
- Meridian.Api.NET 8
- Data Protectionkey ring compartilhado
- .MERIDIANAUTHum cookie
As duas aplicações escrevem e leem um cookie só, através de um key ring num caminho compartilhado. O lado 4.8 chega lá por Microsoft.AspNetCore.DataProtection.SystemWeb e pelo formatador de ticket de interop do Owin.
Por que não simplesmente logar todo mundo duas vezes
A alternativa óbvia (emitir um segundo cookie da aplicação nova e deixar os dois existirem) foi tentada e abandonada numa tarde.
Dois cookies significam dois relógios de expiração, duas renovações de expiração deslizante que se afastam uma da outra, e um logout que precisa alcançar as duas aplicações ou deixa uma sessão viva. Também significa que o momento da verdade se desloca para a primeira requisição que precisar do cookie da outra aplicação, o que é imprevisível e portanto não testável.
Um cookie dá mais trabalho no começo e é uma coisa menor para raciocinar depois.
Dois detalhes que não são detalhes
O elemento machineKey desaparece por completo. O que o substitui não é uma chave de configuração, mas duas linhas de inicialização, e as duas custaram um dia cada.
<machineKey
validationKey="C1B4E5F3A0D8827E4C6B19A2F70D5E8C3B6A94F1D27E0C58B..."
decryptionKey="8F3A0D5C7E92B4160A8D3F5C7E92B4160A8D3F5C7E92B416"
validation="HMACSHA256"
decryption="AES" />
<authentication mode="Forms">
<forms name=".MERIDIANAUTH" loginUrl="~/Account/LogOn" timeout="480"
slidingExpiration="true" protection="All" path="/" />
</authentication>
builder.Services
.AddDataProtection()
.SetApplicationName("Meridian")
.PersistKeysToFileSystem(new DirectoryInfo(
builder.Configuration["DataProtection:KeyRingPath"]
?? throw new InvalidOperationException("DataProtection:KeyRingPath is required.")));
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(o =>
{
o.Cookie.Name = ".MERIDIANAUTH";
o.ExpireTimeSpan = TimeSpan.FromMinutes(480);
o.SlidingExpiration = true;
});
SetApplicationName tem que bater nos dois lados. O Data Protection deriva a
cadeia de propósito a partir do nome da aplicação. Duas aplicações com nomes
diferentes produzem chaves que não conseguem ler os payloads uma da outra
parecendo perfeitamente configuradas: mesmo diretório, mesmos arquivos, mesmas
permissões.
KeyRingPath não tem valor padrão e a inicialização estoura sem ele. Isso é
deliberado, e é o mais importante dos dois.
- Sintoma
- Um usuário loga com sucesso (as credenciais são verificadas, o ticket é escrito) e é imediatamente redirecionado de volta para a tela de login. Repita para sempre. Sem erro, sem exceção, nada no log de nenhuma das aplicações acima de Information.
- Causa
- A aplicação nova não achou um caminho de key ring configurado, então o Data Protection fez o que ele foi projetado para fazer e gerou um efêmero em memória. Depois emitiu um cookie que a aplicação 4.8 não conseguia descriptografar, que redirecionava para o login, que emitia um cookie que a aplicação nova não conseguia descriptografar.
- Pego por
- Ao notar que o diretório do key ring em disco tinha um arquivo XML e um timestamp mais antigo que o deploy.
O modo de falha é o que faz isso valer o parágrafo. Um caminho de key ring ausente é invisível em desenvolvimento, onde só existe uma aplicação e ela conversa alegremente consigo mesma. Ele só falha onde duas aplicações precisam concordar, que é o único ambiente que ninguém roda no próprio laptop.
Então o código estoura na inicialização em vez de assumir um padrão. Um valor de configuração cuja ausência produz uma aplicação que parece funcionar e um sistema quebrado não deveria ter valor padrão.
O que este estágio também consertou de graça
UseForwardedHeaders está neste commit. É o mesmo problema em que o estágio 01
gastou dois dias (uma aplicação montando URLs absolutas a partir do próprio
endereço interno), e não custou nada aqui, porque a essa altura era uma
propriedade conhecida de rodar atrás do gateway, e não uma descoberta.
Esse é a maior parte do argumento a favor de fazer infraestrutura primeiro.
Custonão é temporário
A autenticação das duas aplicações agora depende de um diretório num sistema de arquivos. É um ponto único de falha novo, precisa de backup, e as permissões dele são estruturais de um jeito que as de nenhum arquivo de configuração eram antes.
Comprou
Um cookie, uma sessão, um logout. Um usuário se movendo entre as metades antiga e nova da aplicação não consegue perceber que a fronteira existe, que é a premissa inteira de uma migração strangler.
Tabelas de tarifas, a menor coisa que valia mover
Uma tela somente leitura com lógica de domínio de verdade e nenhuma capacidade de custar dinheiro a alguém, e o padrão do EF Core que truncava todo decimal em silêncio.
O primeiro módulo a se mover é escolhido quase inteiramente pelo que ele não consegue fazer.
Tabelas de tarifas são somente leitura. Sem escrita, sem fila, sem relatório, sem dinheiro se movendo. O que elas têm é lógica de domínio de verdade (busca de faixa, vigência por data) e um caminho que toca todas as partes da stack nova: roteamento no gateway, o cookie compartilhado, EF Core contra o schema em produção, o projeto compartilhado de tarifação.
Cobertura máxima do caminho novo, raio de explosão mínimo. Esse é o critério de seleção inteiro.
O instinto que estava errado
O instinto era começar pela camada de dados, porque ela parece a fundação e todo o resto se apoia nela.
É a pior primeira escolha possível. A camada de dados é a mais acoplada a APIs
que só existem no Framework: ConsignmentRepository lê o tenant dele de
HttpContext.Current e não pode ser chamado por nada que não seja uma requisição
web, o que o job noturno de manifesto descobriu em 2018 e contorna forjando um
contexto de requisição. Começar por ali coloca a peça de trabalho mais difícil
antes de alguém ter qualquer confiança na stack nova, e antes de existir qualquer
evidência de que a abordagem funciona.
Começar por uma tela que exibe números produz algo implantável em dias. Isso importa mais do que arrumação arquitetural no primeiro mês de uma migração, porque a coisa com maior chance de matá-la não é um problema técnico.
Mesmo banco, sem migração de dados
Mesmas tabelas, mesmas linhas, mesmo schema em que a aplicação 4.8 ainda escreve. A migração move código, não dados.
Nada no novo DbContext cria ou migra qualquer coisa. Dois modelos code-first
disputando a posse de um schema é como se perde um fim de semana e depois um
sábado restaurando backup.
O que quebrou
- Sintoma
- Toda tela exibia valores corretos. As leituras eram perfeitas. As escritas descartavam em silêncio a quarta casa decimal de toda coluna de dinheiro, e nada em lugar nenhum reportava erro.
- Causa
- O EF6 tinha sido explicitamente instruído a usar decimal(18,4). O EF Core assume (18,2) por padrão para decimal. Como a aplicação sempre arredondou para duas casas antes de escrever, todo valor já na tabela terminava em dois zeros, então o truncamento não tinha o que truncar e ficou invisível até uma faixa tarifária com uma tarifa genuína de quatro casas passar por ali.
- Pego por
- O harness de paridade. Nada dentro da aplicação teria pego: sem exceção, sem aviso, sem número visivelmente errado em nenhuma tela.
Esta é a lição para levar do diário inteiro. É invisível, é silenciosa, afeta dinheiro, e é causada por um padrão e não por algo que alguém escreveu.
A correção é um loop sobre o modelo em vez de uma annotation por propriedade, porque o modo de falha da abordagem com annotation é esquecer uma.
mb.Entity<Consignment>().Property(x => x.Carriage).HasPrecision(18, 4);
mb.Entity<Consignment>().Property(x => x.FuelSurcharge).HasPrecision(18, 4);
mb.Entity<Consignment>().Property(x => x.Net).HasPrecision(18, 4);
mb.Entity<Consignment>().Property(x => x.Vat).HasPrecision(18, 4);
mb.Entity<Consignment>().Property(x => x.Gross).HasPrecision(18, 4);
mb.Entity<RateBand>().Property(x => x.RatePerKg).HasPrecision(18, 4);
foreach (var property in b.Model.GetEntityTypes()
.SelectMany(t => t.GetProperties())
.Where(p => p.ClrType == typeof(decimal) || p.ClrType == typeof(decimal?)))
{
property.SetColumnType("decimal(18,4)");
}
Seis linhas escritas à mão viram um loop, e a propriedade adicionada ano que vem fica coberta sem ninguém precisar lembrar.
O comportamento que mudou de propósito
A aplicação antiga tinha duas consultas de tarifa. O SQL inline na tela de
reserva usava BETWEEN e ordenava por FromKg decrescente. Uma consulta LINQ na
tela de tabela de tarifas usava >= e <= e ordenava do mesmo jeito. Num peso
exatamente na fronteira (10,0kg numa tabela cujas faixas são 0-10 e 10-25) elas
retornavam faixas diferentes.
As duas estavam no ar havia nove anos. Ninguém sabia.
Escolher entre elas é uma pergunta comercial vestida de técnica, e foi resolvida nessa base: a versão em SQL ganha, porque é a que precificou nove anos de faturas. A versão LINQ nunca foi mais do que uma prévia numa tela da qual ninguém fatura.
Uma mudança de schema
Os três ids de cliente hard-coded em ConsignmentsController.Create viraram uma
coluna.
if (customer.Id == 1104 || customer.Id == 1187 || customer.Id == 1203)
net = net - (net * customer.DiscountPercent / 100m);
else
net = net - (carriage * customer.DiscountPercent / 100m);
ALTER TABLE dbo.Customer
ADD DiscountAppliesToNet BIT NOT NULL
CONSTRAINT DF_Customer_DiscountAppliesToNet DEFAULT (0);
UPDATE dbo.Customer SET DiscountAppliesToNet = 1 WHERE Id IN (1104, 1187, 1203);
É a única mudança que esta migração fez numa tabela existente. Ela ganha o lugar dela duas vezes: o engine de tarifação pode continuar puro, e uma quarta conta na mesma cláusula contratual vira uma linha em vez de um deploy.
Custo
Agora existem duas implementações da tela de tabela de tarifas e as duas são alcançáveis: a 4.8 diretamente, a .NET 8 pelo gateway. Até a rota antiga ser apagada, um report de bug precisa estabelecer qual delas quem reportou estava olhando.
Comprou
Um caminho completo e implantável pela stack nova, provado ponta a ponta, num módulo em que estar errado não custa nada. Tudo depois disto é uma variação de uma rota que já funciona.
MSMQ, um outbox, e um processo que fica em 4.8 para sempre
A dependência que não pode ser migrada de jeito nenhum, então foi encolhida ao máximo e colocada em quarentena.
System.Messaging nunca foi portado para o .NET Core e não será. Não há shim,
não há pacote de compatibilidade, não há port da comunidade, não há jeito
suportado de colocar uma mensagem numa fila MSMQ a partir do .NET 8.
Então a pergunta não é como migramos o MSMQ. É o que fazemos com o fato de que não podemos.
O que foi rejeitado
Reescrever a integração com o depósito sobre RabbitMQ ou Service Bus. A fila é lida por um software de propriedade de um terceiro que foi adquirido em 2020. Mudar o transporte significa uma negociação com outra empresa sobre uma interface que carrega contrato, no cronograma deles, com os testes deles.
Isso é um projeto de verdade com um orçamento de verdade, e não é este. Uma migração que absorve em silêncio a reescrita de uma integração de terceiros é uma migração que não termina, e o jeito como ela falha é que deixa de ser possível dizer o que falta.
Manter todo o fluxo de reserva no 4.8 até o depósito mudar. Isso transforma a única coisa que não pode ser migrada num bloqueador para tudo o que pode. O caminho de escrita de carregamento é o núcleo da aplicação; congelá-lo congela a migração.
O que foi escolhido
A dependência não vai embora. Ela é encolhida ao máximo possível, e uma linha é desenhada em volta dela.
- Action Create.NET 4.8
- SaveChangesEF6 · commitado
- MessageQueue.SendSystem.Messaging
- Depósitoterceiro
- POST /consignments.NET 8
- SaveChangesEF Core · uma transação
- DespatchOutboxtabela
- Sidecar.NET 4.8
- Depósitoterceiro
A dependência 4.8 continua lá. Agora ela é um processo de 200 linhas sem superfície web e sem lógica de negócio, cujo trabalho inteiro é ler uma tabela e escrever numa fila.
O sidecar é o menor refém que a migração conseguiu deixar para trás. Quando o
sistema do depósito for eventualmente substituído, apagar sidecar/ é a mudança
inteira.
Uma correção de corretude que não era o ponto
O código antigo enviava para o MSMQ depois de o SaveChanges() já ter
commitado, fora de qualquer transação:
Duas escritas num banco não podem acontecer pela metade. Uma escrita no banco e um envio para fila podem, e aconteciam.
_db.Consignments.Add(consignment);
_db.SaveChanges();
using (var queue = new MessageQueue(queuePath))
{
// No transaction. If SaveChanges above succeeded and this throws, the
// consignment exists and the depot never hears about it.
queue.Send(msg);
}
db.Consignments.Add(consignment);
outbox.Enqueue(consignment.Reference, DespatchMessage.Format(...));
await db.SaveChangesAsync(ct);
Quando o envio estourava, o carregamento existia e o depósito nunca ficava sabendo. O único jeito de alguém descobrir era um cliente perguntando onde o palete dele tinha ido parar.
O despacho agora é assíncrono por alguns segundos, com o que o depósito não se importa, e não pode ser pulado em silêncio, com o que todo mundo se importa.
O que quebrou
- Sintoma
- O depósito registrou a mesma coleta duas vezes. Duas vans, um palete, e um contato muito pouco impressionado do lado de quem recebia.
- Causa
- O drenador retenta quando um envio falha. Um envio que na verdade tinha dado certo e depois estourou o tempo esperando pela confirmação foi retentado, e o MSMQ já tinha aceitado o primeiro. Não havia como o depósito distinguir os dois.
- Pego por
- Uma rodada de estresse contra uma fila com 5% de timeout de confirmação artificial. Não reproduz de jeito nenhum em condições normais, que é por que teria chegado em produção.
A correção é IdempotencyKey e um índice único. A chave é derivada do corpo da
mensagem, então um reenvio genuíno de conteúdo alterado é outra linha e um retry
cego não é.
A ordem que estava errada primeiro
OutboxDrain reivindica um lote, envia cada mensagem, e depois marca como
enviada. A primeira versão marcava e depois enviava, o que lê mais naturalmente
e nunca deixa uma linha parecendo não enviada quando foi.
O que ela faz, em vez disso, é perder mensagens quando o envio estoura.
O formato geral: enviar duas vezes é recuperável, nunca enviar não é. Diante da escolha entre o modo de falha que duplica e o que descarta, pegue o que duplica, e depois gaste o esforço em tornar as duplicatas detectáveis.
Custonão é temporário
Agora existe no parque um processo .NET Framework 4.8 que vai sobreviver à migração. Ele precisa de um host Windows, do próprio deploy, do próprio monitoramento, e é uma segunda coisa que pode estar fora do ar quando os despachos param de andar.
Comprou
A aplicação .NET 8 pode ser dona do caminho de escrita de carregamento (que é o núcleo do sistema) sem conseguir falar com o MSMQ de jeito nenhum. E a entrega virou transacional no caminho.
O estágio que não produziu código
Vinte relatórios RDLC sem renderizador em .NET 8. A decisão foi não mover nenhum, e escrever a regra de quando isso muda.
Microsoft.ReportViewer.WebForms é um assembly do .NET Framework. Não existe
sucessor em .NET Core ou .NET 5+, e o RDLC não tem outro renderizador: o formato
está amarrado ao único controle que o lê.
São vinte desses arquivos, escritos no designer de relatórios do Visual Studio
entre 2011 e 2017. Um deles tem um sub-relatório que busca as próprias linhas.
Vários fazem bind direto num DataTable devolvido por uma stored procedure.
A estimativa que matou a reescrita
Reescrever vinte relatórios em QuestPDF fica entre três e seis semanas, e a estimativa é larga porque relatórios RDLC são diagramados visualmente e o comportamento deles numa quebra de página é emergente, não especificado. Você não sabe o que um relatório faz no pé da página dois até renderizar um.
Então a pergunta útil não é quanto custa. É o que isso compra.
- Dois documentos voltados ao cliente
- Fatura e manifesto. Vistos por clientes, impressos em papel timbrado, e os únicos em que alguém notaria uma mudança. Mudaram três vezes entre os dois desde 2017.
- Dezoito relatórios operacionais internos
- Throughput de depósito, listas de exceção, reconciliação de fechamento de mês. Lidos por quatro pessoas. Dezenove dos vinte arquivos não são editados desde 2017.
Migrar algo que não muda há oito anos não compra nada além da satisfação de ter migrado. Os relatórios funcionam. Eles renderizam o mesmo PDF que renderizavam em 2017, num runtime que tem suporte até 2031.
O que foi de fato decidido
Todos os vinte ficam. Nenhum código de relatório se moveu, e o diff mostra isso:
não existe geração de PDF em lugar nenhum de modern/.
A regra acordada no lugar: um relatório é reescrito na próxima vez que alguém pedir uma mudança nele, e não antes. A reescrita então é paga pela mudança que a disparou, num momento em que alguém de fato se importa com a saída e consegue dizer se ela está certa.
Pelas evidências atuais isso é perto de nunca para dezoito deles, e essa é a resposta correta para dezoito deles.
O commit que este estágio produziu
Nenhum. O painel de metadados diz isso em vez de mostrar um zero, porque são fatos diferentes e o site foi construído para não conseguir confundi-los.
O único commit que tocou em relatórios em todo o histórico é ecb860b, e ele não
muda relatório nenhum: ele corrige este diário. Um rascunho anterior do README do
repositório afirmava que dois dos vinte tinham sido reescritos. Não tinham.
- Sintoma
- A tabela de dependências do README dizia '18 congelados, 2 reescritos'. O histórico de commits não contém geração de PDF de espécie alguma.
- Causa
- Escrevi o plano no README como se fosse o desfecho. Foi escrito antes da estimativa, e nunca revisado depois de a decisão ter ido para o outro lado.
- Pego por
- Relendo o inventário contra o diff real enquanto escrevia esta página.
Essa correção é a razão de este estágio existir, em vez de ser um parágrafo dentro do estágio 06. Se o inventário do que ainda está em 4.8 não é confiável, o resto do diário também não é: toda outra afirmação nele é exatamente do mesmo tipo, e um leitor não tem jeito independente de checar nenhuma delas.
O único ativo real de um diário de migração é que as más notícias dele são confiáveis.
Custonão é temporário
Vinte relatórios RDLC e uma dependência de ReportViewer permanecem em .NET Framework 4.8 por tempo indeterminado, o que significa que a aplicação web 4.8 não pode ser desligada, o que significa que o key ring compartilhado e o parque com dois runtimes também ficam.
Comprou
Três a seis semanas não gastas redesenhando documentos que quatro pessoas leem e ninguém muda desde 2017.
Carregamentos, movidos por último
A action de 272 linhas. Tudo o que é difícil converge aqui, que é por que todos os outros estágios vieram antes.
ConsignmentsController.Create tem 272 linhas e faz nove coisas. Dinheiro, a
fila, o tenant, a checagem de duplicidade e a trilha de auditoria se encontram
todos dentro dela.
Ela se move por último, e a ordem de todos os estágios anteriores foi escolhida para que ela pudesse. Quando este endpoint foi escrito, as regras de tarifação já estavam extraídas e provadas (02, 03), o cookie já era compartilhado (04), a stack nova já tinha um caminho ponta a ponta funcionando (05), e a fila já tinha uma resposta que não envolvia MSMQ (06). O que sobrou foi orquestração.
- Antes
- Um método de action, 272 linhas, 0 testes
- Depois
- Um endpoint, 215 linhas, delegando a 4 colaboradores nomeados
- Tarifação
- Meridian.Domain.RatingEngine: 4.011 asserções
- Busca de tarifa
- IRateLookup: uma implementação, eram duas
- Despacho
- IDespatchOutbox: transacional, era dispara-e-esquece
- Auditoria
- Não movida. Ainda é um módulo HTTP do lado 4.8.
Três coisas que aqui ficaram piores
Declaradas em vez de escondidas, porque um estágio em que tudo melhorou é um estágio que não foi olhado direito.
O engine de tarifação é chamado duas vezes. Uma para obter o peso taxável, outra para precificá-lo, porque a busca de tarifa precisa do peso, e o peso é assunto do engine. A alternativa era duplicar a fórmula volumétrica no ponto de chamada, que é exatamente como a aplicação antiga terminou com duas delas. Uma chamada redundante é mais barata do que uma segunda cópia de uma regra, mas ela é genuinamente redundante e o próximo leitor vai se perguntar.
A alocação de referência ainda é MAX + 1. Dois atendentes registrando no
mesmo segundo ainda colidem.
Portado, não corrigido. O bug foi preservado exatamente, com um comentário dizendo isso.
var cmd = new SqlCommand(
"SELECT ISNULL(MAX(CAST(SUBSTRING(Reference, 4, 10) AS INT)), 0) FROM Consignment", conn);
var next = (int)cmd.ExecuteScalar() + 1;
return "MER" + next.ToString("D8");
var last = await db.Consignments
.OrderByDescending(c => c.Id)
.Select(c => c.Reference)
.FirstOrDefaultAsync(ct);
var next = last is { Length: > 3 } && int.TryParse(last[3..], out var n) ? n + 1 : 1;
return $"MER{next:D8}";
Corrigir isso direito significa uma sequence, uma mudança de schema e uma conversa com o depósito sobre formatos de referência, porque o formato está no fio naquela mensagem de largura fixa. Isso é um trabalho de verdade e não é desta migração. Fica registrado como dívida num comentário em vez de contrabandeado sob o disfarce de uma migração, que é como migrações adquirem escopo que ninguém aprovou, e como um rollback deixa de ser possível.
A declaração aduaneira em WCF não se moveu. Ela ainda está do lado 4.8, ainda engolindo as exceções dela desde uma quinzena ruim em 2019, e este endpoint não faz a declaração de jeito nenhum. Isso é uma lacuna, não uma decisão. WCF do lado cliente tem sim uma história em .NET 8, então esta simplesmente ainda não foi feita.
O ticket que fechou como efeito colateral
MER-2213, aberto desde 2019: a caixa de busca concatena na cláusula WHERE.
sql += "AND (c.Reference LIKE '%" + q + "%' " +
" OR cu.Name LIKE '%" + q + "%' " +
" OR c.DeliveryPostcode LIKE '%" + q + "%') ";
query = query.Where(c =>
EF.Functions.Like(c.Reference, $"%{q}%") ||
EF.Functions.Like(c.DeliveryPostcode, $"%{q}%"));
Ele ficou aberto seis anos não por ser difícil, mas porque corrigi-lo significava retestar uma tela que ninguém queria retestar. Reescrever a tela ia acontecer de qualquer jeito, então a correção não custou nada.
Vale ser honesto sobre isso em vez de reivindicar como vitória. A migração não priorizou um defeito de segurança. Ela esbarrou num, de passagem.
O tenant deixa de ser ambiente
ConsignmentRepository lia o tenant de HttpContext.Current.Items, o que
significava que só podia ser chamado de dentro de uma requisição web. O job
noturno de manifesto descobriu isso em 2018 e contorna construindo um
HttpContext falso.
O tenant chega como uma claim agora. Essa única mudança é o que vai permitir ao job de manifesto parar de fingir ser uma requisição web quando ele se mover, o que ainda não aconteceu.
Custo
Reservas agora podem chegar por dois caminhos de código contra um banco: a tela 4.8 para humanos, este endpoint para clientes JSON. Até as telas Razor se moverem, qualquer investigação sobre um preço errado começa estabelecendo qual metade o precificou.
Comprou
O método de 272 linhas não é mais o único lugar onde as regras de preço existem, e as regras que ele costumava possuir agora estão cobertas por 4.011 asserções que rodam em um terço de segundo.
Roteamento por Accept, não por caminho
Os coletores do depósito e as telas humanas dividem uma URL. Dividir por tipo de conteúdo moveu um sem mover o outro.
O estágio 08 construiu um endpoint JSON de carregamentos. O problema é que a
aplicação 4.8 já serve /consignments: como views Razor, para pessoas, num
navegador.
As duas plateias querem o mesmo caminho. Os terminais do depósito e o app mobile
fazem POST em /consignments querendo JSON; um atendente abre /consignments
querendo uma tela. O espaço de URLs não os distingue, e não há prefixo de versão
onde se esconder porque esses clientes chamam esse caminho desde 2014.
As opções
Reescrever as telas também. Triplica o tamanho do estágio, e não produz nada para a pessoa segurando um coletor. As views Razor funcionam.
Dar à API um caminho novo: /api/consignments. Limpo, e significa trocar
firmware em terminais de depósito que são atualizados visitando os depósitos
fisicamente. Isso é uma operação de frota, não um deploy.
Dividir pelo header que os clientes já enviam.
Mesmo caminho, mesmos verbos, clientes diferentes. Casar a rota pelo Accept é a única coisa que os separa.
"consignments-api-new": {
"ClusterId": "modern",
"Match": {
"Path": "/consignments/{**catch-all}",
"Headers": [
{
"Name": "Accept",
"Values": [ "application/json" ],
"Mode": "Contains"
}
]
},
"Order": 2
}
Os clientes de máquina se mudaram para .NET 8 e ninguém visitou um depósito. As telas ficaram exatamente onde estavam.
Por que isto está escrito duas vezes
Uma rota que casa por header é invisível num stack trace. Uma requisição que cai na metade errada parece um bug na metade que a recebeu, e nenhuma das duas metades sabe que o header existe: a aplicação 4.8 não faz ideia de que está sendo contornada para alguns chamadores, e a .NET 8 não faz ideia de que está vendo só uma parte do tráfego.
Então isso está no README do gateway além de estar na configuração. Configuração não é documentação: ela diz o que acontece, nunca por quê, e esta é uma regra cujo porquê é a única coisa que torna o próximo incidente tratável.
- Sintoma
- Um terminal de depósito que tinha funcionado a manhã inteira começou a receber HTML. As requisições dele estavam chegando na aplicação 4.8, que renderizava uma view Razor dentro de um parser esperando JSON.
- Causa
- O terminal envia Accept: application/json, */* na maior parte das chamadas, mas Accept: */* num caminho de retry do cliente HTTP dele. Mode: Contains casa a string literal, e */* não contém application/json.
- Pego por
- Comparando as requisições que funcionavam e as que falhavam. Nenhuma das aplicações logou nada incomum: as duas serviram exatamente o que foi pedido.
A correção foi do lado do cliente, porque a alternativa (tratar */* como JSON
no gateway) rotearia a requisição de fallback de um navegador para a API e
quebraria as telas em vez disso. Uma divisão por header só funciona quando o
header de fato discrimina, e descobrir quais dos seus clientes são relaxados com
Accept faz parte do custo de escolher isso.
Custo
Uma URL agora é servida por duas aplicações dependendo de um header, o que é genuinamente surpreendente e vai surpreender alguém de novo. Também significa que as duas metades precisam continuar concordando sobre o que é um carregamento enquanto as duas existirem.
Comprou
Uma classe inteira de cliente mudou de runtime sem mudança no cliente, sem release coordenado, e sem visita a depósito.
Aquele que compila e depois falha em produção
System.Drawing ainda está em 4.8, e o motivo de estar travado é pior do que "sem equivalente em .NET 8".
Label.ashx renderiza a etiqueta de despacho 4x6 que as impressoras do depósito
esperam, através do GDI+, incluindo um código de barras desenhado à mão a partir
de uma tabela de larguras de barra Code 39, porque o componente licenciado foi
abandonado em 2012 quando ninguém conseguiu achar o arquivo de licença.
Ela não se moveu, e vale ser preciso sobre o porquê, porque “System.Drawing não
funciona em .NET 8” é só meia verdade e a metade que é falsa é a perigosa.
Ele compila. Esse é o problema.
System.Drawing.Common ainda existe como pacote. A API está toda lá. Código que
o usa compila sem nenhum aviso, e roda perfeitamente na máquina Windows de um
desenvolvedor.
Do .NET 6 em diante ele lança PlatformNotSupportedException em tempo de
execução em qualquer coisa que não seja Windows. Então a primeira vez que ele
falha é dentro de um contêiner Linux, no primeiro ambiente que rodar um, o que é
um modo de falha estritamente pior do que não compilar, porque passa por todos os
portões que o teriam pego antes.
- O código de barras é geometria, não uma chamada de biblioteca
- As larguras das barras vêm de uma tabela Code 39 e são desenhadas como retângulos preenchidos numa largura de barra estreita calculada. Portar isso significa re-derivar essa geometria contra SkiaSharp ou ImageSharp, não trocar um import.
- A saída tem um teste de aceitação físico
- Os coletores do depósito são exigentes com DPI e zonas de silêncio. O único jeito de saber se uma etiqueta portada está certa é imprimir uma e escaneá-la, num depósito, com o hardware de verdade.
- O endpoint é anônimo
- Um bloco <location> no Web.config o permite sem cookie, porque o firmware do coletor não consegue apresentar um. Movê-lo significa resolver isso também, e a resposta atual é que a referência é difícil de adivinhar.
Por que isto está listado
Porque um inventário que só contém problemas resolvidos não é um inventário.
Este estágio produziu um commit e esse commit só edita um README: ele remove uma linha alegando que o renderizador de etiquetas tinha sido migrado. Não tinha. A alegação foi escrita a partir do plano em vez do diff, no mesmo rascunho que exagerou sobre os relatórios.
Duas dessas num documento só é um padrão, não um deslize, e o padrão é que o resumo foi escrito antes do trabalho e nunca reconferido contra o que de fato chegou. A correção é barata. O hábito que a produziu não é, e é a razão de todo estágio deste diário agora carregar os hashes de commit dele no painel à direita: uma alegação com um hash ao lado pode ser checada em uns quatro segundos, e uma sem hash não pode ser checada de jeito nenhum.
Custo
A aplicação web 4.8 tem que continuar rodando para atender requisições de etiqueta, o que contribui para o mesmo custo de parque que os relatórios. Ela também é o único endpoint anônimo do sistema e está do lado antigo, onde as novas políticas de autorização não alcançam.
Comprou
Nada ainda. Isto é trabalho não feito, registrado como trabalho não feito.
O que ainda roda em .NET Framework, e por quê
O inventário honesto. Um diário de migração que para no último estágio bem-sucedido é um documento de vendas.
A migração não está terminada, e pelos planos atuais partes dela nunca estarão. Esse é o desfecho normal e vale escrever claramente, porque a versão deste documento que termina no estágio 08 com tudo verde seria inútil para quem estiver decidindo se começa uma.
- O sidecar de despacho
- MSMQ não tem sucessor. Limitado a ~200 linhas, sem superfície web, sem lógica de negócio. Apagável no dia em que o sistema do depósito mudar, e não antes. Ver estágio 06.
- Vinte relatórios RDLC
- Microsoft.ReportViewer só existe no Framework. Congelados por decisão, reescritos um a um quando algum mudar. Dezenove não mudam desde 2017. Ver estágio 07.
- Renderização de etiquetas
- System.Drawing. Bloqueada numa reescrita de código de barras com teste de aceitação físico. Conhecida, dimensionada, não iniciada. Ver estágio 10.
- As telas de carregamento
- Views Razor servidas pelo IIS. Os clientes JSON se mudaram no estágio 09; as telas não tinham motivo para seguir nem orçamento para isso.
- A declaração aduaneira
- Uma chamada WCF, ainda do lado antigo, ainda engolindo as exceções dela. WCF do lado cliente tem sim uma história em .NET 8, então esta genuinamente só não foi feita.
- Os módulos de auditoria
- Dois IHttpModules escrevendo uma linha por requisição, de forma síncrona, na thread da requisição. Eles cobrem as duas metades hoje só porque toda requisição ainda passa primeiro pelo IIS.
O que piorou e continuou pior
Todo estágio acima tem um painel de custo. Vários deles estão marcados como não é temporário, e é isso que eles somam.
Existem dois runtimes, dois pipelines de deploy, e dois lugares para olhar quando
algo está lento. A autenticação depende de um diretório num sistema de arquivos
que as duas aplicações precisam conseguir ler, o que é um ponto único de falha
que não existia antes. Uma URL é servida por duas aplicações dependendo de um
header HTTP. As regras de tarifação estão escritas num padrão de linguagem de
2015 porque precisam compilar em net48.
Nada disso é um imposto de transição que some no fim. É o custo corrente de ter escolhido uma migração strangler, e dura o tempo que o sidecar e os relatórios durarem, ou seja, indefinidamente.
Essa é a coisa a pesar contra uma reescrita big-bang. Não “strangler é mais seguro” no abstrato, mas: você está disposto a rodar dois de cada coisa por anos, em troca de nunca ter um fim de semana em que a empresa inteira não consegue registrar frete?
Para um sistema que recebe reservas o dia inteiro, essa troca vale a pena. Para uma ferramenta interna com quarenta usuários e uma janela de manutenção, muitas vezes não vale, e o padrão strangler acaba aplicado a coisas que teriam sido melhor servidas por uma reescrita e um feriado.
A regra que produziu este formato
Uma regra, mantida em todo commit: nenhum commit pode deixar a aplicação sem condição de subir.
É por isso que o formato acima é desarrumado. Uma migração arrumada (uma em que o inventário no fim está vazio e o diagrama de arquitetura não tem linhas pontilhadas) é uma migração que derrubou o sistema para chegar lá.
Quatorze commits, e em cada um deles a Meridian Despatch conseguia registrar uma reserva.
O que eu faria diferente
Nada sobre a ordem. Gateway primeiro, autenticação segundo, menor módulo terceiro estava certo, e os dois dias perdidos no estágio 01 foram o preço de aprender o que o proxy faz com uma aplicação que acha que sabe o próprio endereço.
O que eu mudaria é a escrita. Duas vezes neste diário (os relatórios no estágio
07, as etiquetas no estágio 10) o resumo foi escrito a partir do plano em vez do
diff, e reivindicou trabalho que não tinha acontecido. Os dois foram pegos
relendo o inventário contra o git log, o que significa que a checagem funciona,
mas era uma checagem aplicada no fim em vez de um hábito aplicado o tempo todo.
A correção é o painel à direita de todo estágio desta página. Uma alegação com um hash de commit ao lado pode ser checada em quatro segundos. Uma sem ele não pode ser checada de jeito nenhum, e num documento cujo único ativo real é que as más notícias dele são confiáveis, essa diferença é tudo.