ObjectRepository - Modèle de référentiel en mémoire .NET pour vos projets domestiques

Pourquoi stocker toutes les données en mémoire?


Pour stocker des données de site ou de backend, le premier souhait de la plupart des personnes sensées sera une base de données SQL.


Mais parfois, il vient à l'idée que le modèle de données n'est pas adapté à SQL: par exemple, lors de la construction d'une recherche ou d'un graphe social, vous devez rechercher des relations complexes entre les objets.


La pire situation est lorsque vous travaillez en équipe et qu'un collègue n'est pas en mesure de créer des requêtes rapides. Combien de temps avez-vous consacré à résoudre les problèmes N + 1 et à créer des index supplémentaires afin que SELECT sur la page principale fonctionne dans un délai raisonnable?


Une autre approche populaire est NoSQL. Il y a quelques années, il y avait un grand battage médiatique autour de ce sujet - pour toute opportunité, nous avons déployé MongoDB et apprécié les réponses sous la forme de documents json (en passant, combien de béquilles ont dû être insérées en raison de liens circulaires dans les documents?) .


Pourquoi ne pas essayer de stocker toutes les données dans la mémoire de l'application, en les enregistrant périodiquement dans un stockage arbitraire (fichier, base de données distante)?


La mémoire est devenue bon marché et toutes les données possibles de la plupart des projets de petite et moyenne taille pourront tenir dans 1 Go de mémoire. (Par exemple, mon projet d'accueil préféré - un outil de suivi financier qui conserve des statistiques quotidiennes et un historique de mes dépenses, soldes et transactions pendant un an et demi ne consomme que 45 Mo de mémoire.)


Avantages:


  • L'accès aux données devient plus facile - pas besoin de se soucier des requêtes, du chargement paresseux, des fonctionnalités ORM, de travailler avec des objets C # ordinaires;
  • Il n'y a aucun problème associé à l'accès à partir de différents threads;
  • Très rapide - pas de requêtes réseau, pas de traduction de code dans le langage de requête, pas de (dé) sérialisation des objets;
  • Il est permis de stocker des données sous n'importe quelle forme - au moins en XML sur disque, au moins dans SQL Server, au moins dans Azure Table Storage.

Inconvénients:


  • La mise à l'échelle horizontale est perdue et, par conséquent, aucun déploiement sans interruption de service ne peut être effectué;
  • Si l'application se bloque, vous pouvez partiellement perdre des données. (Mais notre application ne plante jamais, non?)

Comment ça marche?


L'algorithme est le suivant:


  • Au début, une connexion à l'entrepôt de données est établie et les données sont téléchargées;
  • Un modèle d'objet, des indices primaires et des indices de relation (1: 1, 1: plusieurs) sont construits;
  • Un abonnement est créé pour modifier les propriétés des objets (INotifyPropertyChanged) et pour ajouter ou supprimer des éléments à la collection (INotifyCollectionChanged);
  • Lorsque l'abonnement est déclenché - l'objet modifié est ajouté à la file d'attente pour l'écriture dans l'entrepôt de données;
  • Périodiquement (par minuterie), les modifications apportées au stockage sont enregistrées dans le flux d'arrière-plan;
  • Lorsque vous quittez l'application, les modifications apportées au référentiel sont également enregistrées.

Exemple de code


Ajoutez les dépendances nécessaires
//   Install-Package OutCode.EscapeTeams.ObjectRepository    //  ,      //  ,   . Install-Package OutCode.EscapeTeams.ObjectRepository.File Install-Package OutCode.EscapeTeams.ObjectRepository.LiteDb Install-Package OutCode.EscapeTeams.ObjectRepository.AzureTableStorage    //  -       Hangfire // Install-Package OutCode.EscapeTeams.ObjectRepository.Hangfire 

Nous décrivons le modèle de données qui sera stocké dans le référentiel
 public class ParentEntity : BaseEntity {  public ParentEntity(Guid id) => Id = id; }  public class ChildEntity : BaseEntity {  public ChildEntity(Guid id) => Id = id;  public Guid ParentId { get; set; }  public string Value { get; set; } } 

Ensuite, le modèle d'objet:
 public class ParentModel : ModelBase {  public ParentModel(ParentEntity entity)  {    Entity = entity;  }    public ParentModel()  {    Entity = new ParentEntity(Guid.NewGuid());  }    //   1:Many  public IEnumerable<ChildModel> Children => Multiple<ChildModel>(x => x.ParentId);    protected override BaseEntity Entity { get; } }  public class ChildModel : ModelBase {  private ChildEntity _childEntity;    public ChildModel(ChildEntity entity)  {    _childEntity = entity;  }    public ChildModel()  {    _childEntity = new ChildEntity(Guid.NewGuid());  }    public Guid ParentId  {    get => _childEntity.ParentId;    set => UpdateProperty(() => _childEntity.ParentId, value);  }    public string Value  {    get => _childEntity.Value;    set => UpdateProperty(() => _childEntity.Value, value);  }    //       public ParentModel Parent => Single<ParentModel>(ParentId);    protected override BaseEntity Entity => _childEntity; } 

Et enfin, la classe de référentiel elle-même pour accéder aux données:
 public class MyObjectRepository : ObjectRepositoryBase {  public MyObjectRepository(IStorage storage) : base(storage, NullLogger.Instance)  {    IsReadOnly = true; //  ,            AddType((ParentEntity x) => new ParentModel(x));    AddType((ChildEntity x) => new ChildModel(x));      //   Hangfire       Hangfire  ObjectRepository    // this.RegisterHangfireScheme();      Initialize();  } } 

Créez une instance d'ObjectRepository:


 var memory = new MemoryStream(); var db = new LiteDatabase(memory); var dbStorage = new LiteDbStorage(db);  var repository = new MyObjectRepository(dbStorage); await repository.WaitForInitialize(); 

Si le projet utilisera HangFire
 public void ConfigureServices(IServiceCollection services, ObjectRepository objectRepository) {  services.AddHangfire(s => s.UseHangfireStorage(objectRepository)); } 

Insérez un nouvel objet:


 var newParent = new ParentModel() repository.Add(newParent); 

Dans cet appel, l'objet ParentModel est ajouté à la fois au cache local et à la file d'attente d'écriture dans la base de données. Par conséquent, cette opération prend O (1) et vous pouvez immédiatement travailler avec cet objet.


Par exemple, pour rechercher cet objet dans le référentiel et vous assurer que l'objet renvoyé est la même instance:


 var parents = repository.Set<ParentModel>(); var myParent = parents.Find(newParent.Id); Assert.IsTrue(ReferenceEquals(myParent, newParent)); 

Que se passe-t-il avec ça? Set <ParentModel> () renvoie un TableDictionary <ParentModel> , qui contient ConcurrentDictionary <ParentModel, ParentModel> et fournit des fonctionnalités supplémentaires pour les index primaires et secondaires. Cela vous permet d'avoir des méthodes de recherche par ID (ou autres index personnalisés arbitraires) sans énumérer complètement tous les objets.


Lorsque des objets sont ajoutés à ObjectRepository , un abonnement est ajouté pour modifier leurs propriétés. Par conséquent, toute modification des propriétés entraîne également l'ajout de cet objet à la file d'attente d'écriture.
La mise à jour des propriétés de l'extérieur ressemble à l'utilisation d'un objet POCO:


 myParent.Children.First().Property = "Updated value"; 

Vous pouvez supprimer un objet des manières suivantes:


 repository.Remove(myParent); repository.RemoveRange(otherParents); repository.Remove<ParentModel>(x => !x.Children.Any()); 

Cela ajoute également l'objet à la file d'attente de suppression.


Comment fonctionne la conservation?


ObjectRepository lors de la modification d'objets suivis (ajout ou suppression et modification de propriétés) déclenche l'événement ModelChanged , auquel IStorage est abonné . Les implémentations d' IStorage, lorsqu'un événement ModelChanged se produit, résument les modifications dans 3 files d'attente - ajouter, mettre à jour et supprimer.


En outre, les implémentations IStorage lors de l'initialisation créent un minuteur qui, toutes les 5 secondes, enregistre les modifications.


De plus, il existe une API pour forcer un appel de sauvegarde: ObjectRepository.Save () .


Avant chaque enregistrement, les premières opérations sans signification sont supprimées des files d'attente (par exemple, les événements en double - lorsque l'objet a changé deux fois ou l'ajout / la suppression rapide d'objets), puis seulement l'enregistrement lui-même.


Dans tous les cas, l'objet entier est enregistré, il est donc possible que les objets soient enregistrés dans un ordre différent de celui qu'ils ont été modifiés, y compris les versions plus récentes des objets qu'au moment de l'ajout à la file d'attente.


Qu'y a-t-il d'autre?


  • Toutes les bibliothèques sont basées sur .NET Standard 2.0. Il peut être utilisé dans n'importe quel projet .NET moderne.
  • L'API est thread-safe. Les collections internes sont basées sur ConcurrentDictionary , les gestionnaires d'événements ont des verrous ou n'en ont pas besoin.
    La seule chose à retenir est d'appeler ObjectRepository.Save ();
  • Index personnalisés (nécessitent l'unicité):

 repository.Set<ChildModel>().AddIndex(x => x.Value); repository.Set<ChildModel>().Find(x => x.Value, "myValue"); 

Qui l'utilise?


Personnellement, j'ai commencé à utiliser cette approche dans tous les projets de loisirs, car elle est pratique et ne nécessite pas de dépenses importantes pour écrire une couche d'accès aux données ou déployer une infrastructure lourde. Personnellement, en règle générale, le stockage de données dans litedb ou dans un fichier me suffit généralement.


Mais dans le passé, lorsque EscapeTeams, le démarrage tardif, avait été créé avec l'équipe (ils pensaient que c'était de l'argent - mais non, de l'expérience ) - ils utilisaient Azure Table Storage pour stocker des données.


Plans futurs


Je voudrais corriger l'un des principaux inconvénients de cette approche - la mise à l'échelle horizontale. Pour ce faire, vous avez besoin soit de transactions distribuées (sic!), Soit de prendre une décision ferme que les mêmes données de différentes instances ne doivent pas changer, ou de les laisser changer selon le principe "qui est le dernier - c'est vrai."


D'un point de vue technique, je vois le schéma suivant possible:


  • Stocker EventLog et Snapshot au lieu du modèle d'objet
  • Trouver d'autres instances (ajouter des points de terminaison de toutes les instances? Découverte Udp? Maître / esclave? Aux paramètres)
  • Répliquez entre les instances EventLog via l'un des algorithmes de consensus, tels que RAFT.

Il y a aussi un autre problème qui me dérange: la suppression en cascade ou la détection de cas de suppression d'objets référencés à partir d'autres objets.


Code source


Si vous lisez jusqu'ici - alors seul le code reste à lire, il peut être
trouvé sur github .

Source: https://habr.com/ru/post/fr452232/


All Articles