Files
AdamuSw/src/Persistence/EntityFramework/Readme.md
2026-07-14 19:00:35 +03:00

154 lines
7.5 KiB
Markdown

# Persistence by Entity Framework Core
This project implements the persistence layer by using the Entity Framework Core.
The database should be free to choose, but we test with PostgreSQL and it might
be possible that the migrations are only working on PostgreSQL, too.
## Entity Model
It's important to note, that the object model of MUnique.OpenMU.DataModel is not
directly used. Instead, every class of the model gets inherited to be used with
EF-Core.
We did this because the application code should not be aware of the boilerplate
code of all of the persistence aspects.
Of course, this is not done manually, but with the help of automatic code generation.
As some critical features are still missing by EF-Core, we can easily add some
workarounds to the whole data model here.
The code generation adds the following stuff to the inherited entity classes:
* Object Identifier, "Id"-Property as GUID
* Overriding of Equals and GetHashCode, based on the Id-Property
* Foreign Key Id-Properties for navigation properties
* Because every type is different, it has do to the following stuff for
navigation properties:
* Overrides every property which uses the base type, adds a NotMappedAttribute
and sets the foreign key property value in the setter.
* Adds new properties which use the inherited type and maps it to the
Foreign Key Id-Property. Getter and Setter is accessing the base property.
* For the same reason, (1:n) collection properties need special handling:
* It adds new "Raw" properites which are ICollections of the inherited classes
* It initializes the base collection properties with [1:n collection adapters](../CollectionAdapter.cs)
which adapt between base and inherited classes, accessing the "Raw" collection.
* For the n:m-collection properties (which are a bit tricky to get detected as such):
* Additional join entity classes are created
* Collection properties for these join entities are added
* [Many-to-many collection adapters](ManyToManyCollectionAdapter.cs) are used
* It adds a schema name ("config" or "data") to the TableAttribute
* It creates a new [DbContext](Model/ExtendedTypeContext.Generated.cs) which
defines Ignores for all base types and adds join definitions for all 1:n
and n:m relationships.
If you're interested of how the result looks like, have a look at the subfolder *Model*.
## Schemas
To keep access to game configuration data restricted for account contexts
(= connected clients), we put configuration and account data into separate schemas.
There might be additional schemas and users for the friend and guild servers.
### Configuration
The different contexts (identified by their full class name) can use different
database users. These are configured in the [ConnectionSettings.xml](ConnectionSettings.xml)
file.
During install, only the user for the MUnique.OpenMU.Persistence.EntityFramework.EntityDataContext
should exist, the other user and their rights will be created by this user.
So this user should have the required rights to grant this rights.
## Repository Pattern
All application logic uses the contexts provided by the [PersistenceContextProvider](PersistenceContextProvider.cs).
The provided contexts load their data not directly from ef core contexts,
but access repositories. That's because we want to eagerly load objects which
means we want to retrieve the complete object graph with all dependent data,
when we access our "Contexts".
We're also caching configuration data with this approach, by using so called [ConfigurationTypeRepositories](ConfigurationTypeRepository.cs).
Each entity type has basically one repository, some are manually implemented,
some are generic. There is a [RepositoryProvider](RepositoryProvider.cs) which
holds and "provides" all of these repositories.
The implementation of this pattern is probably not the same as all of the examples
of what you'll find at the internet. Workarounds and different requirements result
in different implementations ;-)
The [CacheAwareRepositoryProvider](CacheAwareRepositoryProvider.cs) holds two other
repository providers. One which provides repositories which actually load the data
from the database, and another one which returns repositories with cached data,
based on the loaded GameConfiguration. The CacheAwareRepositoryProvider first tries
to retrieve a repository for the cached data. If none is found, it takes the other.
### Loading whole object graphs
As the application code expects a fully loaded object when loading through the
contexts, we have to load objects as a whole.
It might also be possible to lazy-load dependent data, but this can lead to bad
performance here. As we know that we actually need the whole data in our use cases,
it makes sense to fully load the objects up-front.
For example, when we want to load a game configuration, we want to load all collection
and navigation properties and cache them for later accesses.
That seems slow at the start of the server, but it's an advantage when loading
accounts which reference a lot of configuration data later.
When loading accounts, they can be used to resolve configuration navigation
properties of the player's data.
Additionally, with such an access pattern it should be a no brainer to accellerate
data access by implementing persistence for a NoSQL document database, too.
Here we would use one document for each Account, keeping the game configuration
in another document.
All this happens in the [GenericRepository of T](GenericRepository.cs).
To load all whole object graphs, the repositories are iterating through the
navigation and collection properties of the loaded object.
If they are not loaded yet (specified by each EntityEntry), it loads them by
accessing the repository of the property type.
This is also the reason why every type needs it's own repository, otherwise it
wouldn't work.
#### Loading full objects by json queries
The repositories of Accounts and GameConfiguration objects load their objects
(and all dependencies) in one go - by using some JSON functions of PostgreSQL.
You can read about it
[here](https://munique.net/loading-complex-data-with-postgresql-json-functions/)
and [here](https://github.com/MUnique/OpenMU/issues/10).
### Context management
The concept of a context is probably most common when working with
EntityFramework ORMs.
The application code does not directly use DBContext of the entity framework.
We have an abstraction by using an interface IContext and some inherited ones
for different use-cases. The [base implementation](EntityFrameworkContext.cs)
holds the DBContext which is used by the repositories.
Other ORMs do have similar concepts (like NHibernate sessions) and we try to keep
the context abstraction compatible to such use cases.
In case of this server, it gives each connected account one account context.
All of the changes of one account are tracked in this context.
When we want to save an account we just call SaveChanges on this context.
Sounds easy, right? :)
However, it gets difficult when more than one account is involved by an entity,
e.g. Items which are changing the owner to another account, by trade or drop/pickup.
When doing a trade, the items of both trading players are attached to a new *trade context*,
which saves the changes on trade completion.
After a trade finished, the traded items are attached to their corresponding contexts
of their players.
With this abstraction of a context we can also use other ORMs such as NHibernate
or NoSQL databases which save documents with just one call - we would save and
commit the corresponding data of such sessions at the SaveChanges method.