Na DoorDash, a maior parte do nosso backend é atualmente baseada em Django e Python. A base de código Django dos nossos primeiros dias evoluiu e amadureceu ao longo dos anos à medida que o nosso negócio cresceu. Para continuar a crescer, também começámos a migrar a nossa aplicação monolítica para uma arquitetura de microsserviços. Aprendemos muito sobre o que funciona bem e o que não funciona com o Django, e esperamos poder partilhar algumas dicas úteis sobre como trabalhar com esta popular estrutura web.
Cuidado com as "aplicações"
Django tem esse conceito de "aplicações", que são vagamente descritas na documentação como "um pacote Python que provê algum conjunto de funcionalidades". Enquanto eles fazem sentido como bibliotecas reutilizáveis que podem ser conectadas em diferentes projetos, a utilidade deles no que diz respeito a organizar o código da sua aplicação principal é menos clara.
Existem algumas implicações de como você define suas "apps" que você deve estar ciente. A maior delas é que o Django rastreia migrações de modelos separadamente para cada aplicação. Se você tem ForeignKey's ligando modelos em diferentes aplicações, o sistema de migração do Django vai tentar inferir um gráfico de dependência para que as migrações sejam executadas na ordem correta. Infelizmente, esse cálculo não é perfeito e pode levar a alguns erros ou até mesmo dependências circulares complexas, especialmente se você tiver muitas aplicações.
Originalmente, organizámos o nosso código num conjunto de "aplicações" separadas para organizar diferentes funcionalidades, mas tínhamos muitos ForeignKey's entre aplicações. As migrações que tínhamos verificado ocasionalmente acabavam num estado em que funcionavam bem em produção, mas não em desenvolvimento. No pior dos casos, nem sequer eram reproduzidas numa base de dados em branco. Cada sistema pode ter uma permutação diferente de estados de migração para diferentes aplicações, e executar manage.py migrate
pode não funcionar com todas elas. Em última análise, concluímos que ter todas estas aplicações separadas conduziu a uma complexidade e a dores de cabeça desnecessárias.
Rapidamente descobrimos que, se tínhamos estes ForeignKey's a cruzar diferentes aplicações, então talvez não fossem realmente aplicações separadas. Na verdade, tínhamos apenas uma "aplicação" que podia ser organizada em diferentes pacotes. Para melhor refletir isto, deitámos fora as nossas migrações e migrámos tudo para uma única "aplicação". Esse processo não foi a tarefa mais fácil de se fazer (Django também usa nomes de app para preencher ContentType's e nomear tabelas de banco de dados - mais sobre isso depois) mas nós ficamos felizes de ter feito isso. Isso também significa que todas as nossas migrações tiveram que ser linearizadas, e enquanto isso veio com desvantagens, nós descobrimos que elas foram superadas pelo benefício de ter um sistema de migração previsível e estável.
Para resumir, aqui estão as nossas sugestões para qualquer programador que esteja a iniciar um projeto Django:
- Se não compreender bem a utilidade das aplicações, ignore-as e opte por uma única aplicação para o seu backend. Continua a poder organizar uma base de código em crescimento sem utilizar aplicações separadas.
- Se pretender criar aplicações separadas, terá de ser muito intencional na forma como as define. Seja muito explícito e minimize quaisquer dependências entre diferentes aplicativos. (Se estiver a planear migrar para microserviços mais tarde, posso imaginar que as "aplicações" podem ser uma construção útil para definir precursores de um futuro microserviço).
Organize as suas aplicações dentro de um pacote
Já que estamos no tópico de aplicativos, vamos falar um pouco sobre organização de pacotes. Se você seguir o tutorial "Getting started" do Django, o pacote manage.py startapp
irá criar uma "app" no nível superior do diretório do projeto. Por exemplo, uma aplicação chamada foo seria acessível como import foo.models…
. Aconselhamo-lo vivamente a colocar as suas aplicações (e todo o seu código Python) num pacote Python, nomeadamente o pacote que é criado com django-admin startproject
.
No exemplo do tutorial do Django, em vez de:
mysite/
mysite/
__init__.py
polls/
__init__.py
Nós sugerimos:
mysite/
mysite/
__init__.py
polls/
__init__.py
Esta é uma mudança pequena e subtil, mas previne conflitos de espaço de nomes entre a sua aplicação e bibliotecas Python de terceiros. Em Python, os módulos de nível superior vão para um espaço de nomes global e precisam de ser nomeados de forma única. Como exemplo, a biblioteca Python de um fornecedor que usámos, Segment, tem o nome analytics
. Se tivéssemos um analytics
definida como um módulo de nível superior, não haveria forma de distinguir entre os dois pacotes no seu código.
Nomear explicitamente as tabelas da base de dados
A sua base de dados é mais importante, mais duradoura e mais difícil de alterar após o facto do que a sua aplicação. Sabendo isto, faz sentido que seja muito intencional sobre a forma como está a conceber o esquema da sua base de dados, em vez de permitir que uma estrutura Web tome essas decisões por si.
Enquanto você controla amplamente o esquema do banco de dados no Django, existem algumas coisas que ele lida por padrão que você deve saber. Por exemplo, o Django gera automaticamente um nome de tabela para seus modelos, com o padrão de <app_name>_<model_name_lowercased>
. Em vez de confiar nestes nomes gerados automaticamente, deve considerar definir a sua própria convenção de nomes e nomear todas as suas tabelas manualmente, utilizando Meta.db_table
.
class Foo(Model):
class Meta:
db_table = 'foo'
O outro aspeto a ter em conta é ManyToManyFields
. O Django facilita a geração de relacionamentos muitos-para-muitos usando este campo e criará a tabela de junção com nomes de tabelas e colunas gerados automaticamente. Em vez de o fazer, sugerimos vivamente que crie e nomeie manualmente as tabelas de junção (utilizando a função through
palavra-chave). Será muito mais fácil aceder diretamente à tabela e, francamente, descobrimos que é irritante ter tabelas ocultas.
Esses podem parecer detalhes menores, mas desacoplar a nomenclatura do seu banco de dados dos detalhes de implementação do Django é uma boa ideia porque haverá outras coisas que tocarão seus dados além do Django ORM, como data warehouses. Isso também permite que você renomeie suas classes modelo mais tarde se você mudar de idéia. Finalmente, isso vai simplificar coisas como dividir tabelas em serviços separados ou fazer a transição para um framework web diferente.
Evitar GenericForeignKey
Se o puder evitar, evite utilizar GenericForeignKey's. Perde funcionalidades de consulta de bases de dados como junções (select_related
) e recursos de integridade de dados, como restrições de chave estrangeira e exclusões em cascata. A utilização de tabelas separadas é geralmente uma solução melhor, e pode aproveitar modelos de base abstractos se estiver à procura de reutilização de código.
Dito isto, há situações em que ainda pode ser útil ter uma tabela que pode apontar para diferentes tabelas. Se for o caso, seria melhor fazer sua própria implementação e não é tão difícil (você só precisa de duas colunas, uma para o ID do objeto, a outra para definir o tipo). Uma coisa que nós não gostamos no GenericForeignKey é que ele tem uma dependência no framework ContentTypes do Django, que armazena identificadores para tabelas em uma tabela de mapeamento chamada django_contenttypes
.
Não é muito divertido lidar com essa tabela. Para começar, utiliza o nome da sua aplicação (app_label
) e a classe de modelo Python (model
) como colunas para mapear um modelo Django para um id inteiro, que é então armazenado dentro da tabela com a GFK. Se você alguma vez mover modelos entre aplicações ou renomear suas aplicações, você vai ter que fazer alguns ajustes manuais nessa tabela. Mais importante ainda, o facto de uma tabela comum conter estes mapeamentos de GFK irá complicar bastante as coisas se quiser mover as suas tabelas para serviços e bases de dados separados. À semelhança da secção anterior sobre a nomeação explícita das suas tabelas, também deve possuir e definir os seus próprios identificadores de tabela. Quer queira usar um número inteiro, uma cadeia de caracteres ou qualquer outra coisa para o fazer, qualquer um deles é melhor do que depender de um ID arbitrário definido numa tabela aleatória.
Manter as migrações seguras
Se você está usando Django 1.7 ou posterior e está usando um banco de dados relacional, você provavelmente está usando o sistema de migração do Django para gerenciar e migrar seu esquema. Quando você começa a rodar em escala, existem algumas nuances importantes a serem consideradas sobre o uso de migrações Django.
Em primeiro lugar, terá de se certificar de que as suas migrações são seguras, o que significa que não causarão tempo de inatividade quando forem aplicadas. Vamos supor que seu processo de implantação envolve chamar manage.py migrate
automaticamente antes de implementar o código mais recente nos seus servidores de aplicações. Uma operação como adicionar uma nova coluna será segura. Mas não deve ser tão surpreendente que deletar uma coluna vai quebrar as coisas, já que o código existente ainda estaria referenciando a coluna inexistente. Mesmo que não existam linhas de código que referenciem o campo deletado, quando o Django busca um objeto (e.g. Model.objects.get(..)
, sob o capô executa um SELECT
em cada coluna que é definida no modelo. Como resultado, praticamente qualquer acesso do Django ORM a essa tabela vai gerar uma exceção.
Pode evitar este problema certificando-se de que executa as migrações depois de o código ser implementado, mas isso significa que as implementações têm de ser um pouco mais manuais. Pode ser complicado se os desenvolvedores tiverem feito várias migrações antes de uma implantação. Outra solução alternativa é transformar essas e outras migrações perigosas em migrações "no-op", fazendo com que as migrações sejam puramente operações de "estado". Você precisará então executar a operação DROP
operações após o desdobramento.
class Migration(migrations.Migration):
state_operations = [ORIGINAL_MIGRATIONS]
operations = migrations.SeparateDatabaseAndState(
state_operations=state_operations
)
Naturalmente, a eliminação de colunas e tabelas não é a única operação a que deve estar atento. Se tiver uma grande base de dados de produção, existem muitas operações inseguras que podem bloquear a sua base de dados ou tabelas e levar a tempo de inatividade. Os tipos específicos de operações dependerão da variante de SQL que estiver a utilizar. Por exemplo, no PostgreSQL, adicionar colunas com um índice ou que não sejam anuláveis a uma tabela grande pode ser perigoso. Aqui está um artigo muito bom do BrainTree que resume algumas das migrações perigosas no PostgreSQL.
Elimine as suas migrações
Conforme seu projeto evolui e acumula mais e mais migrações, elas levarão mais e mais tempo para serem executadas. Por design, Django precisa incrementar cada migração começando da primeira para construir seu estado interno do esquema do banco de dados. Não só isso vai atrasar os deploys de produção, mas os desenvolvedores também terão que esperar quando eles inicialmente configuram seu banco de dados de desenvolvimento local. Se tiver múltiplas bases de dados, este processo irá demorar ainda mais, porque o Django irá reproduzir todas as migrações em cada base de dados, independentemente de a migração afetar essa base de dados.
A menos que você evite migrações Django completamente, a melhor solução que nós encontramos é apenas fazer uma limpeza periódica e "esmagar" suas migrações. Uma opção é tentar a funcionalidade de "squashing" embutida no Django. Outra opção, que tem funcionado bem para nós, é fazer isso manualmente. Coloque tudo no diretório django_migrations
eliminar os ficheiros de migração existentes e executar manage.py makemigrations
para criar migrações novas e consolidadas.
Reduzir o atrito da migração
Se muitas dúzias de desenvolvedores estão trabalhando na mesma base de código Django, você pode frequentemente encontrar condições de corrida com merging em migrações de banco de dados. Por exemplo, considere um cenário onde o histórico de migração atual no master se parece com:
0001_a
0002_b
Suponhamos agora que o engenheiro A gera migração 0003_c
no seu ramo local, mas antes de o poder fundir, o engenheiro B chega lá primeiro e verifica a migração 0003_d
. Se o engenheiro A fundir agora o seu ramo, qualquer pessoa que tente executar migrações depois de obter o código mais recente irá deparar-se com o erro "Conflicting migrations detected; multiple leaf nodes in the migration graph: (0003_c, 0003_d)".
No mínimo, isto resulta na necessidade de linearizar manualmente as migrações ou criar uma migração de fusão, causando fricção no processo de desenvolvimento da equipa. A equipa de engenharia da Zenefits discute este problema mais detalhadamente numa publicação do blogue, da qual retirámos inspiração para melhorar esta situação.
Em menos de uma dúzia de linhas de código, conseguimos resolver uma forma mais geral deste problema no caso em que temos múltiplos Django aplicações. Fizemos isso substituindo o handle()
do nosso método makemigrations
para gerar um manifesto de migração de vários aplicativos:
Aplicando isto ao exemplo acima, o ficheiro de manifesto teria uma entrada doordash: 0002_b
para a nossa aplicação. Se gerarmos um novo ficheiro de migração 0003_c
fora do HEAD, o diff no ficheiro de manifesto será aplicado de forma limpa e pode ser fundido tal como está:
- doordash: 0002_b
+ doordash: 0003_c
No entanto, se as migrações estiverem desactualizadas, por exemplo, se um engenheiro só tiver 0001_a
localmente e gera uma nova migração 0002_d
o diff do ficheiro de manifesto não será aplicado de forma limpa e, por conseguinte, o Github declarará que existem conflitos de fusão:
- doordash: 0001_a
+ doordash: 0002_d
O engenheiro seria então responsável por resolver o conflito antes que o Github permita que o pull request seja mesclado. Se você tem testes de integração nos quais os merges de código são bloqueados (o que qualquer empresa desse tamanho deveria ter), essa também é outra motivação para manter a suíte de testes rápida!
Evitar modelos gordos
O Django promove um padrão de "modelo gordo" onde você coloca a maior parte da sua lógica de negócio dentro dos métodos do modelo. Enquanto isso é o que nós usamos inicialmente, e pode até ser bem conveniente, nós percebemos que isso não escala muito bem. Com o tempo, as classes de modelo ficam inchadas com métodos e tornam-se extremamente longas e difíceis de ler. Os mixins são uma forma de reduzir um pouco a complexidade, mas não parecem ser a solução ideal.
Esse padrão pode ser um pouco estranho se você tem alguma lógica que não precisa operar em uma instância completa de um modelo obtido do banco de dados, mas precisa apenas da chave primária ou de uma representação simplificada armazenada no cache. Adicionalmente, se você quiser sair do Django ORM, acoplar sua lógica a modelos vai complicar esse esforço.
Dito isso, a real intenção por trás desse padrão é manter a API/view/controller leve e livre de lógica excessiva, o que é algo que nós defendemos fortemente. Ter lógica dentro dos métodos do modelo é um mal menor, mas você pode querer considerar manter os modelos leves e focados na camada de dados. Para que isso funcione, você precisará descobrir um novo padrão e colocar sua lógica de negócios em alguma camada que esteja entre a camada de dados e a camada de API/apresentação.
Cuidado com os sinais
O framework de sinais do Django pode ser útil para desacoplar eventos de ações, mas um caso de uso que pode ser problemático são pre/post_save
sinais. Eles podem ser úteis para pequenas coisas (por exemplo, verificar quando invalidar um cache), mas colocar muita lógica em sinais pode tornar o fluxo do programa difícil de rastrear e ler. Passar argumentos personalizados ou informações através de um sinal não é realmente possível. Também é muito difícil, sem o uso de alguns hacks, desabilitar um sinal de disparar em certas condições (por exemplo, se você quiser atualizar em massa alguns modelos sem disparar sinais caros).
O nosso conselho é limitar a utilização destes sinais e, se os utilizar, evitar colocar neles outra lógica que não seja simples e barata. Você também deve manter esses sinais organizados num lugar previsível e consistente (por exemplo, perto de onde os modelos são definidos), para tornar seu código fácil de ler.
Evite utilizar o ORM como a principal interface para os seus dados
Se você está criando e atualizando diretamente objetos de banco de dados de muitas partes da sua base de código com chamadas para a interface Django ORM (Model.objects.create()
ou Model.save()
), talvez você queira rever essa abordagem. Descobrimos que usar o ORM como a interface principal para modificar dados tem algumas desvantagens.
O principal problema é que não existe uma forma simples de executar acções comuns quando um modelo é criado ou atualizado. Suponhamos que, sempre que o ModeloA é criado, queremos também criar uma instância do ModeloB. Ou quer detetar quando um determinado campo foi alterado em relação ao seu valor anterior. Para além dos sinais, a sua única solução alternativa é sobrecarregar uma grande quantidade de lógica em Model.save()
o que pode tornar-se muito complicado e incómodo.
Uma solução para este problema é estabelecer um padrão no qual todas as operações importantes da base de dados (criar/atualizar/eliminar) são encaminhadas através de algum tipo de interface simples que envolva a camada ORM. Isto dá-lhe pontos de entrada simples para adicionar lógica adicional antes ou depois dos eventos da base de dados. Adicionalmente, desacoplar um pouco o código da sua aplicação da interface do modelo lhe dará a flexibilidade para sair do Django ORM no futuro.
Não colocar modelos Django em cache
Se você está trabalhando no escalonamento da sua aplicação, você provavelmente está tirando vantagem de uma solução de cache como Memcached ou Redis para reduzir as consultas ao banco de dados. Embora possa ser tentador armazenar em cache instâncias de modelos Django, ou mesmo os resultados de Querysets inteiros, existem algumas ressalvas que você deve estar ciente.
Se você migrar seu schema (adicionar/alterar/excluir campos do seu modelo), o Django na verdade não lida com isso de forma muito graciosa quando se trata de instâncias em cache. Se o Django tentar ler uma instância de model que foi escrita no cache a partir de uma versão anterior do schema, ele vai praticamente morrer. Por baixo dos panos, ele está deserializando um objeto pickled do backend do cache, mas esse objeto será incompatível com o código mais recente. Isso é mais um detalhe infeliz da implementação do Django do que qualquer outra coisa.
Você pode simplesmente aceitar que terá algumas exceções após uma implantação com uma migração de modelo e limitar os danos definindo TTLs de cache razoavelmente curtos. Melhor ainda, evitar o cache de modelos como regra geral. Em vez disso, armazene em cache apenas as chaves primárias e procure os objetos no banco de dados. (Tipicamente, as pesquisas de chaves primárias são bastante baratas. São as consultas SELECT para encontrar esses IDs que são caras).
Levando isso um passo adiante para evitar acessos ao banco de dados completamente, você ainda pode fazer cache de modelos Django com segurança se você mantiver apenas uma cópia em cache de uma instância de modelo. Então, é bastante trivial invalidar esse cache após mudanças no esquema do modelo. Nossa solução foi apenas criar um hash único dos campos conhecidos e adicionar isso à nossa chave de cache (e.g. Foo:96f8148eb2b7:123
). Sempre que um campo é adicionado, renomeado ou eliminado, as alterações de hash invalidam efetivamente a cache.
Conclusão
O Django é definitivamente uma estrutura poderosa e cheia de funcionalidades para começar o seu serviço de backend, mas há subtilezas a ter em conta que podem poupar-lhe dores de cabeça mais tarde. Definir aplicações Django cuidadosamente e implementar uma boa organização de código desde o início irá ajudá-lo a evitar trabalho de refatoração desnecessário mais tarde. Enquanto isso, tomando controle total sobre seu esquema de banco de dados e sendo deliberado sobre como você usa as funcionalidades do Django como GenericForeignKey's e o ORM, você pode garantir que você não está muito acoplado ao framework e migrar para outras tecnologias ou arquiteturas no futuro.
Pensando nessas coisas, você pode manter a flexibilidade para evoluir e escalar seu backend no futuro. Esperamos que algumas das coisas que aprendemos sobre o uso do Django o ajudem a construir suas próprias aplicações!