Eu tenho uma fábrica class XFactory
que cria objetos de class X
. Como as instâncias X
são muito grandes, o objetivo principal da fábrica é armazená-las em cache, da maneira mais transparente possível para o código do cliente. Como os objetos class X
são imutáveis, o código a seguir parece razoável:
# module xfactory.py
import x
class XFactory:
_registry = {}
def get_x(self, arg1, arg2, use_cache = True):
if use_cache:
hash_id = hash((arg1, arg2))
if hash_id in _registry:
return _registry[hash_id]
obj = x.X(arg1, arg2)
_registry[hash_id] = obj
return obj
# module x.py
class X:
# ...
É um bom padrão? (Eu sei que não é o padrão de fábrica real.) Há algo que eu deva mudar?
Agora, acho que às vezes quero armazenar em cache X
objetos no disco. Usarei pickle
para esse fim e armazenarei como valores nos _registry
nomes dos arquivos dos objetos em conserva, em vez de referências aos objetos. Obviamente, _registry
ele próprio teria que ser armazenado persistentemente (talvez em um arquivo pickle próprio, em um arquivo de texto, em um banco de dados ou simplesmente fornecendo aos arquivos pickle os nomes de arquivos que contêm hash_id
).
Exceto agora, a validade do objeto em cache depende não apenas dos parâmetros passados para get_x()
, mas também da versão do código que criou esses objetos.
A rigor, mesmo um objeto armazenado em cache na memória pode se tornar inválido se alguém modificar x.py
ou qualquer uma de suas dependências e recarregá-lo enquanto o programa estiver em execução. Até agora, eu ignorei esse perigo, pois parece improvável para minha aplicação. Mas certamente não posso ignorá-lo quando meus objetos são armazenados em cache para armazenamento persistente.
O que eu posso fazer? Suponho que eu poderia tornar o hash_id
mais robusto calculando o hash de uma tupla que contém argumentos arg1
e arg2
, assim como o nome do arquivo e a data da última modificação de x.py
todos os módulos e arquivos de dados dos quais depende (recursivamente). Para ajudar a excluir arquivos de cache que nunca mais serão úteis, eu adicionaria à _registry
representação unilateral das datas modificadas para cada registro.
Mas mesmo essa solução não é 100% segura, pois teoricamente alguém pode carregar um módulo dinamicamente, e eu não saberia disso analisando estaticamente o código-fonte. Se eu me esforçar ao máximo e assumir que todos os arquivos do projeto são dependentes, o mecanismo ainda será interrompido se algum módulo pegar dados de um site externo, etc.).
Além disso, a frequência de alterações x.py
e suas dependências é bastante alta, levando à invalidação pesada do cache.
Portanto, imaginei que seria melhor desistir de alguma segurança e invalidar o cache apenas quando houver uma incompatibilidade óbvia. Isso significa que class X
teria um identificador de validação de cache no nível de classe que deve ser alterado sempre que o desenvolvedor acreditar que ocorreu uma alteração que deve invalidar o cache. (Com vários promotores, um identificador de invalidação separado é necessário para cada um.) Este identificador é hash juntamente com arg1
e arg2
e torna-se parte das chaves hash armazenado na _registry
.
Como os desenvolvedores podem esquecer de atualizar o identificador de validação ou não perceber que invalidaram o cache existente, seria melhor adicionar outro mecanismo de validação: class X
pode haver um método que retorne todos os "traços" conhecidos de X
. Por exemplo, se X
for uma tabela, posso adicionar os nomes de todas as colunas. O cálculo de hash também incluirá as características.
Posso escrever esse código, mas receio que esteja perdendo algo importante; e também estou me perguntando se talvez já exista uma estrutura ou pacote que possa fazer tudo isso. Idealmente, eu gostaria de combinar cache na memória e em disco.
EDITAR:
Pode parecer que minhas necessidades possam ser bem atendidas por um padrão de piscina. Em uma investigação mais aprofundada, no entanto, não é o caso. Eu pensei em listar as diferenças:
Um objeto pode ser usado por vários clientes?
- Pool: Não, cada objeto precisa ser retirado e depois verificado quando não é mais necessário. O mecanismo preciso pode ser complicado.
- XFactory: Sim. Os objetos são imutáveis e podem ser usados por infinitos clientes ao mesmo tempo. Nunca é necessário criar uma segunda cópia do mesmo objeto.
O tamanho da piscina precisa ser controlado?
- Piscina: Frequentemente, sim. Nesse caso, a estratégia para fazer isso pode ser bastante complicada.
- XFactory: Não. Um objeto deve ser entregue sob demanda ao cliente e, se um objeto existente for inadequado, será necessário criar um novo.
Todos os objetos são livremente substituíveis?
- Pool: Sim, os objetos geralmente são livremente substituíveis (ou, se não, é trivial verificar qual objeto o cliente precisa).
- XFactory: Absolutamente não, e é muito difícil descobrir se um determinado objeto pode atender a uma determinada solicitação do cliente. Depende da disponibilidade de um objeto existente criado com (a) os mesmos argumentos e (b) com a mesma versão do código-fonte. A parte (b) não pode ser verificada pelo XFactory, portanto, solicita ao cliente que o ajude. O cliente cumpre essa responsabilidade de duas maneiras. Primeiro, o cliente pode incrementar qualquer um dos vários contadores de versão internos designados (um por desenvolvedor). Isso não pode acontecer no tempo de execução, apenas um desenvolvedor pode alterar esses contadores quando acredita que a alteração no código-fonte torna inutilizáveis os objetos existentes. Segundo, um cliente retornará alguns invariantes sobre os objetos necessários e o XFactory verificará se esses invariantes não foram violados antes de servir o objeto ao cliente. Se alguma dessas verificações falhar,
O impacto no desempenho precisa de uma análise cuidadosa?
- Pool: Sim, em alguns casos, um pool realmente prejudica o desempenho se a sobrecarga do gerenciamento de objetos for maior que a sobrecarga da criação / destruição de objetos.
- XFactory: Não. Sabe-se que os custos de computação dos objetos em questão são muito altos, e carregá-los da memória ou do disco é sem dúvida superior ao recalculá-los do zero.
Quando os objetos são destruídos?
- Piscina: quando a piscina está fechada. Talvez também possa destruir objetos se for solicitado (parcialmente) a liberar recursos ou se determinados objetos não forem usados há algum tempo.
- XFactory: sempre que um objeto foi criado com a versão do código-fonte que não é mais atual, como evidenciado por violação invariável ou contradição de correspondência. O processo de localizar e destruir esses objetos no momento certo é bastante complicado. Além disso, a invalidação baseada em tempo de todos os objetos pode ser implementada para reduzir os riscos acumulados do uso de objetos inválidos. Como o XFactory nunca tem certeza de que é o único proprietário de um objeto, essa invalidação é melhor alcançada por um "contador de versão" adicional nos objetos do cliente, que é incrementado programaticamente periodicamente, e não por um desenvolvedor.
Que considerações especiais existem para o ambiente multithread?
- Pool: precisa evitar colisões no check-in / check-in de objetos (não deseja fazer check-out de um objeto para dois clientes)
- XFactory: precisa evitar colisões na criação de objetos (não deseja criar dois objetos com base em duas solicitações idênticas)
O que precisa ser feito se o cliente não liberar um objeto?
- Pool: pode ser necessário disponibilizar o objeto para outras pessoas depois de esperar algum tempo.
- XFactory: Não aplicável. Os clientes não notificam o XFactory sobre quando são concluídos com o objeto.
Os objetos precisam ser modificados?
- Pool: talvez seja necessário redefinir o estado padrão antes de ser reutilizado.
- XFactory: Não, os objetos são imutáveis.
Existem considerações especiais relacionadas à persistência de objetos?
- Piscina: Normalmente não. Um pool é sobre como economizar o custo de criação de objetos, para que todos os objetos sejam mantidos na memória (a leitura do disco anularia a finalidade).
- XFactory: Sim, o XFactory é sobre economizar o custo de realizar cálculos complexos, portanto, faz sentido armazenar objetos pré-calculados no disco. Como resultado, o XFactory precisa lidar com os problemas típicos do armazenamento persistente; por exemplo, na inicialização, ele precisa se conectar ao armazenamento persistente, obter dele os metadados sobre quais objetos estão disponíveis no momento e estar pronto para carregá-los na memória, se solicitado. E o objeto pode estar em um dos três estados: "não existe", "existe no disco", "existe na memória". Enquanto o XFactory está em execução, o estado pode mudar apenas em uma direção (à direita nesta sequência).
Em resumo, a complexidade do pool está nos itens 1, 2, 4, 6 e possivelmente 5, 7, 8. A complexidade do XFactory está nos itens 3, 6, 9. A única sobreposição é o item 6, e realmente não é o núcleo. função de pool ou XFactory, mas sim uma restrição no design comum a qualquer padrão que precise funcionar em um ambiente multithread.