Codificação com Mark: abstraindo junções e subconjuntos em Python
Mark Smith11 min read • Published Mar 19, 2024 • Updated Mar 19, 2024
SNIPPET
Avalie esse Tutorial
Este tutorial falará sobre os padrões de design do MongoDB — especificamente, o padrão de subconjunto — e mostrará como você pode criar uma abstração em seu modelo de dados Python que oculta como os dados são realmente modelados em seu banco de dados.
Este é o terceiro tutorial de uma série! Sinta-se à vontade para conferir o primeiro ou osegundo tutorial, se quiser, mas não é necessário se você quiser apenas continuar lendo.
Este tutorial é vagamente baseado em alguns episódios de uma transmissão ao vivo que apresento, chamada "Coding with Mark". Estou transmitindo às quartas-feiras às 2 GMT (ou seja, 9 ET ou 6 PT, se você acordar cedo!). Se esse tempo não funcionar para você, você sempre pode acompanhar assistindo às gravações!
Atualmente, estou criando uma biblioteca experimental de camada de acesso a dados que deve fornecer um kit de ferramentas para abstrair modelos de documentos complexos da camada de lógica comercial do aplicativo que os está usando.
O objetivo do docbridge, meu mapeador objeto-documento, é abstrair o modelo de dados usado MongoDB no do modelo de dados usado por um Python programa . Com uma base de código de qualquer tamanho, você precisa de algo assim porque, caso contrário, toda vez que você alterar seu modelo de dados (em seu banco de dados), você precisará alterar o modelo de objeto (em seu código). Por ter uma camada de abstração, você localiza todo esse mapeamento em uma única área de sua base de código, e essa é a única parte que precisa mudar quando você muda seu modelo de dados. Essa capacidade de alterar seu modelo de dados realmente permite que você aproveite a flexibilidade do MongoDB do document model.
No primeiro tutorial, mostrei uma abstração bem simples, o FallbackField, que tentaria vários nomes de campos diferentes em um documento até encontrar um que existisse, e então retornaria esse valor. Esta foi uma implementação muito simples do padrão Schema Versioning.
O MongoDB permite que você armazene matrizes em seus documentos, de forma nativa. Os valores nesses arrays podem ser tipos primitivos, como números, strings, datas ou até mesmo subdocumentos. Mas, às vezes, esses arrays podem ficar muito grandes, e o padrão de subconjunto descreve uma técnica em que o subconjunto mais importante do array (geralmente apenas os primeiros itens) é armazenado diretamente no array incorporado, e todos os itens excedentes são armazenados em outros documentos e consultados somente quando necessário.
Isso resolve dois problemas de design: primeiro, recomendamos que você não armazene mais do que itens 200 em uma matriz, pois quanto mais itens você tiver, mais lento será o banco de dados em percorrer os campos em cada documento. Em segundo lugar, o padrão de subconjunto também responde a uma pergunta que vi muitas vezes quando ensinamos modelagem de dados: "Como faço para impedir que minha matriz cresça tanto que o documento se torne maior do que o limite de 16MB?" Já que estamos falando do assunto, evite que seus documentos fiquem tão grandes - isso geralmente implica que você poderia melhorar seu modelo de dados, por exemplo, separando os dados em documentos separados ou, se estiver armazenando muitos dados binários, poderia mantê-los fora do banco de dados, em um armazenamento de objetos.
Antes de analisar como abstrair uma pesquisa para os itens extras da array que não estão incorporados ao documento de origem, primeiro implementarei um tipo wrapper para uma array BSON. Isso pode ser usado para declarar campos de array em uma classe
Document
, em vez do tipoField
que implementei em artigos anteriores.Vamos definir um
SequenceField
para mapear a array de um documento no modelo de objeto da minha camada de acesso. A funcionalidade principal de um SequenceField é que você pode especificar um tipo para os itens da array e, quando você iterar pela sequência, ele retornará objetos desse tipo, em vez de apenas produzir o tipo armazenado no documento.Um exemplo concreto seria a classe UserProfile de uma API de mídia social, que armazenaria uma lista de objetos de seguidor. Criei alguns documentos de amostra com um roteiro Python usando Faker. Um documento de exemplo tem a seguinte aparência:
1 { 2 "_id": { "$oid": "657072b56731c9e580e9dd70" }, 3 "user_id": "4", 4 "user_name": "@tanya15", 5 "full_name": "Deborah White", 6 "birth_date": { "$date": { "$numberLong": "931219200000" } }, 7 "email": "deanjacob@yahoo.com", 8 "bio": "Music conference able doctor degree debate. Participant usually above relate.", 9 "follower_count": { "$numberInt": "59" }, 10 "followers": [ 11 { 12 "_id": { "$oid": "657072b66731c9e580e9dda6" }, 13 "user_id": "58", 14 "user_name": "@rduncan", 15 "bio": "Rich beautiful color life. Relationship instead win join enough board successful." 16 }, 17 { 18 "_id": { "$oid": "657072b66731c9e580e9dd99" }, 19 "user_id": "45", 20 "user_name": "@paynericky", 21 "bio": "Picture day couple democratic morning. Environment manage opportunity option star food she. Occur imagine population single avoid." 22 }, 23 # ... other followers 24 ] 25 }
Posso modelar esses dados usando duas classes: uma para os dados de perfil de nível superior e outra para os dados de resumo dos seguidores desse perfil (incorporados na matriz).
1 class Follower(Document): 2 _id = Field(transform=str) 3 user_name = Field() 4 5 class Profile(Document): 6 _id = Field(transform=str) 7 followers = SequenceField(type=Follower)
Se eu quiser percorrer todos os seguintes de uma instância de perfil, cada item deve ser uma instância
Follower
:1 profile = Profile(SOME_BSON_DATA) 2 for follower in profile.followers: 3 assert isinstance(follower, Follower)
Este comportamento pode ser implementado de forma semelhante à classe
Field
, implementando-o como um descritor, com um método__get__
que, nesse caso, produz um Follower
construído para cada item na array BSON subjacente. O código é um pouco assim:1 class SequenceField: 2 """ 3 Allows an underlying array to have its elements wrapped in 4 Document instances. 5 """ 6 7 def __init__( 8 self, 9 type, 10 field_name=None, 11 ): 12 self._type = type 13 self.field_name = field_name 14 15 def __set_name__(self, owner, name): 16 """ 17 Called when the enclosing Document subclass (owner) is defined. 18 """ 19 self.name = name # Store the attribute name. 20 21 # If a field-name mapping hasn't been provided, 22 # the BSON field will have the same name as the attribute name. 23 if self.field_name is None: 24 self.field_name = name 25 26 def __get__(self, ob, cls): 27 """ 28 Called when the SequenceField attribute is accessed on the enclosed 29 Document subclass. 30 """ 31 try: 32 # Lookup the field in the BSON, and return an array where each item 33 # is wrapped by the class defined as type in __init__: 34 return [ 35 self._type(item, ob._db) 36 for item in ob._doc[self.field_name] 37 ] 38 except KeyError as ke: 39 raise ValueError( 40 f"Attribute {self.name!r} is mapped to missing document property {self.field_name!r}." 41 ) from ke
Isso é muito código, mas grande parte dele é duplicado a partir de
Field
- Vou corrigir isso com alguma herança em algum momento. A parte mais importante está próxima ao final:1 return [ 2 3 self._type(item, ob._db) 4 5 for item in ob._doc[self.field_name] 6 ]
No exemplo concreto acima, isso resultaria em algo como este código fictício:
1 return [ 2 Follower(item, db=None) for item in profile._doc["followers"] 3 ]
O conjunto de dados que criei para trabalhar com isso armazena apenas os primeiros 20 seguidores em um documento de perfil. O restante é armazenado em uma collection "seguidores" e é agrupado para armazenar até 20 seguintes por documento, em um campo chamado "seguidores". O campo "user_id" diz a quem pertencem os membros. Um único documento na collection "seguidores" tem a seguinte aparência:
O Padrão de Bucket é uma técnica para colocar muitos subdocumentos pequenos juntos em um documento de bucket, o que pode tornar mais eficiente a recuperação de documentos que geralmente são recuperados juntos, e pode manter os tamanhos dos índices baixos. A desvantagem é que isso torna a atualização de subdocumentos individuais um pouco mais lenta e mais complexa.
Tenho uma coleção em que cada documento contém uma matriz de seguidores - um balde "" de seguidores. Mas o que eu quero é uma consulta que retorne documentos individuais de seguidores. Vamos detalhar como essa consulta funcionará:
- Pretendo procurar todos os documentos para um user_id específico.
- Para cada item em seguidores - cada item é um seguidor - quero gerar um único documento para esse seguidor.
- Queremos reestruturar cada documento para que ele contenhaapenas as informações do seguidor, não as informações do bucket.
Isso é o que eu amo nos pipelines de agregação — depois de criar essas etapas, muitas vezes posso converter cada etapa em um estágio de pipeline de agregação.
Etapa 1: Procure todos os documentos de um usuário específico:
1 {"$match": {"user_id": "4"}}
Observe que este estágio codificou o valor "4" para o campo "user_id". Explicarei mais tarde como valores dinâmicos podem ser inseridos nessas queries. Isso gera um único documento, um bucket, contendo muitos membros, em um campo chamado "seguidores":
1 { 2 "user_name": "@tanya15", 3 "full_name": "Deborah White", 4 "birth_date": { 5 "$date": "1999-07-06T00:00:00.000Z" 6 }, 7 "email": "deanjacob@yahoo.com", 8 "bio": "Music conference able doctor degree debate. Participant usually above relate.", 9 "user_id": "4", 10 "follower_count": 59, 11 "followers": [ 12 { 13 "_id": { 14 "$oid": "657072b66731c9e580e9dda6" 15 }, 16 "user_id": "58", 17 "user_name": "@rduncan", 18 "bio": "Rich beautiful color life. Relationship instead win join enough board successful." 19 }, 20 { 21 "bio": "Picture day couple democratic morning. Environment manage opportunity option star food she. Occur imagine population single avoid.", 22 "_id": { 23 "$oid": "657072b66731c9e580e9dd99" 24 }, 25 "user_id": "45", 26 "user_name": "@paynericky" 27 }, 28 { 29 "_id": { 30 "$oid": "657072b76731c9e580e9ddba" 31 }, 32 "user_id": "78", 33 "user_name": "@tiffanyhicks", 34 "bio": "Sign writer win. Look television official information laugh. Lay plan effect break expert message during firm." 35 }, 36 . . . 37 ], 38 "_id": { 39 "$oid": "657072b56731c9e580e9dd70" 40 } 41 }
Etapa 2: produza um documento para cada seguidor — o estágio $unwind pode fazer exatamente isso:
1 {"$unwind": "$followers"}
Isso instrui o MongoDB a retornar um documento para cada item da array "followers". Todo o conteúdo do documento será incluído, mas a arrayde membros será substituída pelo subdocumentode seguidor único a cada vez. Isso gera vários documentos, cada um contendo um único seguidor no campo "seguidores":
1 # First document: 2 { 3 "bio": "Music conference able doctor degree debate. Participant usually above relate.", 4 "follower_count": 59, 5 "followers": { 6 "_id": { 7 "$oid": "657072b66731c9e580e9dda6" 8 }, 9 "user_id": "58", 10 "user_name": "@rduncan", 11 "bio": "Rich beautiful color life. Relationship instead win join enough board successful." 12 }, 13 "user_id": "4", 14 "user_name": "@tanya15", 15 "full_name": "Deborah White", 16 "birth_date": { 17 "$date": "1999-07-06T00:00:00.000Z" 18 }, 19 "email": "deanjacob@yahoo.com", 20 "_id": { 21 "$oid": "657072b56731c9e580e9dd70" 22 } 23 } 24 25 # Second document 26 { 27 "_id": { 28 "$oid": "657072b56731c9e580e9dd70" 29 }, 30 "full_name": "Deborah White", 31 "email": "deanjacob@yahoo.com", 32 "bio": "Music conference able doctor degree debate. Participant usually above relate.", 33 "follower_count": 59, 34 "user_id": "4", 35 "user_name": "@tanya15", 36 "birth_date": { 37 "$date": "1999-07-06T00:00:00.000Z" 38 }, 39 "followers": { 40 "_id": { 41 "$oid": "657072b66731c9e580e9dd99" 42 }, 43 "user_id": "45", 44 "user_name": "@paynericky", 45 "bio": "Picture day couple democratic morning. Environment manage opportunity option star food she. Occur imagine population single avoid." 46 } 47 48 # . . . More documents follow
Etapa 3: Reestruture o documento, puxando o valor "follower" até o nível superior do documento. Há uma etapa especial para fazer isso: $replaceRoot:
1 {"$replaceRoot": {"newRoot": "$followers"}},
Adicionar o estágio acima resulta em cada documento contendo um único seguidor, no nível superior:
1 # Document 1: 2 { 3 "_id": { 4 "$oid": "657072b66731c9e580e9dda6" 5 }, 6 "user_id": "58", 7 "user_name": "@rduncan", 8 "bio": "Rich beautiful color life. Relationship instead win join enough board successful." 9 } 10 11 # Document 2 12 { 13 "_id": { 14 "$oid": "657072b66731c9e580e9dd99" 15 }, 16 "user_id": "45", 17 "user_name": "@paynericky", 18 "bio": "Picture day couple democratic morning. Environment manage opportunity option star food she. Occur imagine population single avoid." 19 } 20 } # . . . More documents follow
Resumindo, a consulta se parece com isto:
1 [ 2 {"$match": {"user_id": "4"}}, 3 {"$unwind": "$followers"}, 4 {"$replaceRoot": {"newRoot": "$followers"}}, 5 ]
Eu expliquei a consulta que eu quero que seja executada toda vez que eu fizer uma iteração no campo de seguidores na minha biblioteca de abstração de dados. Agora, mostrarei como ocultar essa query (ou qualquer query necessária) na implementação de SequenceField.
Agora, gostaria de alterar o comportamento do SequenceField para que ele faça o seguinte:
- Percorra os subdocumentos incorporados e produza cada um deles, agrupado por tipo (o callable que envolve cada subdocumento).
- Se o usuário chegar ao final da matriz incorporada, faça uma consulta para procurar o restante dos seguidores e produzi-los um por um, também agrupados por tipo.
Primeiro, alterarei o método
__init__
para que o usuário possa fornecer dois parâmetros adicionais:- A coleção que contém os documentos extras, superset_collection
- A query a ser executada nessa collection para retornar documentos individuais, superset_query
O resultado é:
1 class Field: 2 def __init__( 3 self, 4 type, 5 field_name=None, 6 superset_collection=None, 7 superset_query: Callable = None, 8 ): 9 self._type = type 10 self.field_name = field_name 11 self.superset_collection = superset_collection 12 self.superset_query = superset_query
A consulta terá de ser fornecida como um chamável, ou seja, uma função, expressão lambda ou método. A razão para isso é que a geração da consulta normalmente precisará de acesso a parte do estado do documento (nesse caso, o
user_id
, para construir a consulta e procurar os documentos seguidores corretos). O chamável é armazenado na instância do campo e, em seguida, quando a pesquisa é necessária, ele chama o chamável, passando-lhe o documento que contém o campo, para que o chamável possa procurar o usuário "_id" no dicionário_doc
agrupado.Agora que o usuário pode fornecer informações suficientes para pesquisar os seguidores extras (o superconjunto), alterei o método
__get__
para realizar a pesquisa quando ficar sem seguidores incorporados. Para tornar isso mais simples de escrever, aproveitei a preguiça. Duas vezes! Veja como:Preguiça Parte 1: Quando você executa uma consulta chamando
find
ou aggregate
, a consulta não é executada imediatamente. Em vez disso, o método retorna imediatamente um cursor. Os cursores são preguiçosos - o que significa que eles não fazem nada até que você comece a usá-los, iterando sobre seu conteúdo. Assim que você começa a iterar ou fazer um loop sobre o cursor, ele consulta o banco de dados e começa a produzir resultados.Preguiça parte 2: A maioria das funções no módulo principal do Python
itertools
também é preguiçosa, incluindo a funçãochain
.A cadeia é chamada com um ou mais iteráveis como argumentos e, em seguida, só começa a percorrer os argumentos posteriores quando os iteráveis anteriores se esgotam (o que significa que o código percorreu todo o conteúdo do iterável).Eles podem ser combinados para criar um único iterável que nunca solicitará nenhum seguidor extra do banco de dados, a menos que o código solicite especificamente mais itens depois de percorrer os itens incorporados:
1 embedded_followers = self._doc["followers"] # a list 2 cursor = followers.find({"user_id": "4"}) # a lazy database cursor 3 4 # Looping through all_followers will only make a database call if you have 5 # looped through all of the contents of embedded_followers: 6 all_followers = itertools.chain(embedded_followers, cursor)
O código real é um pouco mais flexível, pois suporta consultas de busca e agregação. Ele reconhece o tipo porque as consultas find são fornecidas como dicts e as consultas agregadas são listas.
1 def __get__(self, ob, cls): 2 if self.superset_query is None: 3 # Use an empty sequence if there are no extra items. 4 # It's still iterable, like a cursor, but immediately exits. 5 superset = [] 6 else: 7 # Call the superset_query callable to obtain the generated query: 8 query = self.superset_query(ob) 9 10 # If the query is a mapping, it's a find query, otherwise it's an 11 # aggregation pipeline. 12 if isinstance(query, Mapping): 13 superset = ob._db.get_collection(self.superset_collection).find(query) 14 elif isinstance(query, Iterable): 15 superset = ob._db.get_collection(self.superset_collection).aggregate( 16 query 17 ) 18 else: 19 raise Exception("Returned was not a mapping or iterable.") 20 21 try: 22 # Return an iterable that first yields all the embedded items, and 23 24 return chain( 25 [self._type(item, ob._db) for item in ob._doc[self.field_name]], 26 (self._type(item, ob._db) for item in superset), 27 ) 28 except KeyError as ke: 29 raise ValueError( 30 f"Attribute {self.name!r} is mapped to missing document property {self.field_name!r}." 31 ) from ke
Adicionei alguns comentários ao código acima, então espero que você possa ver a relação entre o código simplificado acima dele e o código real aqui.
A implementação
Profile
e Follower
agora é uma questão de fornecer a query (envolvida em uma expressão lambda) e a collection que deve ser queryda.1 # This is the same as it was originally 2 class Follower(Document): 3 _id = Field(transform=str) 4 user_name = Field() 5 6 7 def extra_followers_query(profile): 8 return [ 9 { 10 "$match": {"user_id": profile.user_id}, 11 }, 12 {"$unwind": "$followers"}, 13 {"$replaceRoot": {"newRoot": "$followers"}}, 14 ] 15 16 class Profile(Document): 17 _id = Field(transform=str) 18 followers = SequenceField( 19 type=Follower, 20 superset_collection="followers", 21 superset_query=lambda ob: extra_followers_query, 22 )
Um aplicativo que usasse a definição
Profile
acima poderia procurar oProfile
com "user_id" de "4" e imprimir os nomes de usuário de todos os seus membros com um código como este:1 for follower in profile.followers: 2 print(follower.user_name)
Viu como a consulta extra agora faz parte da definição de mapeamento do tipo e não do código que lida com os dados? Esse é o tipo de abstração que eu queria fornecer quando comecei a construir essa biblioteca experimental. Tenho mais planos, então fique junto! Mas antes de implementar mais abstrações de dados, primeiro preciso implementar atualizações - isso é algo que descreverei em meu próximo tutorial.
Este é agora o terceiro tutorial da minha série de abstração de dados do Python, e devo reconhecer que este era o código que idealizei quando me ocorreu pela primeira vez a ideia da biblioteca docbridge. Foi muito gratificante chegar a esse ponto e, como venho desenvolvendo tudo com práticas de desenvolvimento orientadas a testes, já existe uma boa cobertura de código.
Se estiver procurando mais informações sobre aggregation pipelines, dê uma olhada em Aggregations práticas do MongoDB — ou agora, você pode comprar uma versão expandida do livro em papel.
Se você estiver interessado nos tópicos de abstração e na arquitetura de código Python em geral, pode comprar o livroPadrões de arquitetura com Python ou lê-lo online em CosmicPython.com
Eu transmito ao vivo na maioria das semanas, geralmente às 2 h UTC às quartas-feiras. Se isso lhe parece interessante, confira o canal doMongoDB no Youtube . Aguardo você lá!
Principais comentários nos fóruns
Ainda não há comentários sobre este artigo.