Trochę mnie nie było, ale przyszedł czas złapać za kubek kakałka i wrócić do działania. Ostatnio intryguje mnie ogrom pewnych elementów. Obecnie świat baz danych jest tak rozległy, że żadna osoba nie ogarnie wszystkich możliwości w pojedynkę. Przed programistą / architektem stoi ogrom wyborów. Jedne bazy danych bardziej pasują pod zaawansowane systemy CRM, inne pod aplikacje social mediowe. Nie mniej, każdy produkcyjny system, na jakimś etapie, zaczyna korzystać z bazy danych. Dlatego w dzisiejszym poście chciałbym wam przedstawić jak za pomocą platformy .NET Core komunikować się z bazą danych MongoDB.
Czym jest MongoDB?
Zaczynimy od krótkiego wyjaśnienia co to właściwie jest. Na wikipedii możemy znaleźć bardzo ładnie sformułowaną regułkę (pewnie nie tylko tam, ale jak już jest):
Otwarty, nierelacyjny system zarządzania bazą danych napisany w języku C++. Charakteryzuje się dużą skalowalnością, wydajnością oraz brakiem ściśle zdefiniowanej struktury obsługiwanych baz danych. Zamiast tego dane składowane są jako dokumenty w stylu JSON, co umożliwia aplikacjom bardziej naturalne ich przetwarzanie, przy zachowaniu możliwości tworzenia hierarchii oraz indeksowania.
Czym jest nierelacyjna baza danych? To taka, w której nie występują tabelki :) Nie ma rekordów, zapytań SQL, czy większości rzeczy znanych z takich baz danych MySQL, SqlServer, PostgreSQL. Więc co tam znajdziemy? Kolekcje i dokumenty. Wielu uważa to za tożsame twory bez wyraźnych różnic. Pomimo faktycznych podobieństw w funkcjonowaniu (obie służą do przechowywania liczby rekordów/dokumentów) to jednak różnice są dość znaczące. Kolekcje są bardziej elastyczne w przypadku przechowywania w niej wielu typu obiektów(dokumentów), natomiast w zwykłej bazie relacyjnej jesteśmy ograniczeni do jasno zdefiniowanych kolumn.
A czym jest Dokument? I co znaczy, że jest on w stylu JSON? To dowolny obiekt, który jesteśmy w stanie serializować do formatu danych JSON. Tak jak wspominałem wcześniej w jednej kolekcji możemy przechowywać wiele typów dokumentów. Dla przykładu, mając jakąś kolekcję "col", możemy tam przechować zarówno:
{
"_type": "person",
"name": "Kamil",
"nickname": "bd90",
"subjects": [
{ "_type": "subject", "name": "Mathematica", "mark": 3.0 }
{ "_type": "subject", "name": "Programing basics", "mark": 2.0 }
]
}
jak także
{
"_type": "role",
"name": "Admin",
"isSuper": true
}
Jaki jest cel by mieć wszystko w jednej kolekcji? Pomaga to np. obniżyć koszty korzystania z Azure CosmosDB, gdzie za każdą kolekcję płacimy dodatkowo. Na marginesie: Azure CosmosDB ma zaimplementowany interfejs MongoDB? Pozwala to komunikować się z bazą CosmosDB za pomocą tego samego kodu, co MongoDB. Innym przykładowym zastosowaniem, jest obiekt produktu w sklepie. Jako że w większości przypadków nie jesteśmy w stanie otrzymać nie zmiennej liczby parametrów produktu to w relacyjnej bazie danych użyjemy modelu: "encja-atrybut-wartość" gdzie w bazie NoSql możemy po prostu zapisać nieco inny obiekt.
Uruchomienie MongoDB
Jak zwykle, w przypadku stawiania bazy danych lub innego software-u na maszynie lokalnej, mamy dwie możliwości. Pierwszą z nich jest po prostu zainstalowanie MongoDB. Wszystkie informacje potrzebne do instalacji są zawarte w dokumentacji.
Drugą opcją jest użycie obrazu docker-a. Wtedy wystarczy jedna komenda
$ docker run -n my-mongo -d -p 27017:27017 -v ~/data:/data/db mongo
Esencjonalne jest użycie Docker Volume, aby zapewnić trwałość danych przy restarcie kontenera. Jeśli nie wiesz czym jest, lub jak użyć Docker Volume, zapraszam do mojego artykułu na ten temat.
Poznajmy C# Mongo Client
Jak makaron w rosole tak ten artykuł ma meritum. Komunikacja z bazą MongoDB wymaga używania Mongo .NET driver. Biblioteka jest prosta w użyciu i nie wymaga wiele, wystarczy zainstalować trzy paczki nugeta do projektu:
Teraz możemy ustawić połączenie z bazą danych. Trzeba ustanowić instancję obiektu MongoClient, gdzie jako pierwszy parametr podajemy connection string-a. Jeśli uruchomiliście MongoDB na lokalnej maszynie, z default-owymi parametrami, to powinno wyglądać tak
var client = new MongoClient("mongodb://localhost:27017");
Jeżeli korzystamy z ReplicationSet-a w mongo, to kolejne instancje mongo powinniśmy podawać po przecinkach np. "mongodb://localhost:27017,localhost:27018,localhost:27019".
Integracja z .NET Core MVC
Dzięki obecności kontenera zależności wbudowanego w .NET Core MVC możemy, za pomocą jednej linijki, zarejestrować instancje naszego klienta bazy danych jako Singleton.
services.AddSingleton(new MongoClient("mongodb://localhost:27017"));
Singleton? W jakim celu? Nie powinniśmy tworzyć i zarządzać instancjami w osobnych wątkach? Zakładam, że kilka osób, czytając ten artykuł, może mieć podobne pytania. Nie chcąc trzymać w niepewności spieszę z odpowiedzią. Dokumentacja MongoDB mówi nam jasno, że:
The
MongoClient
instance actually represents a pool of connections to the database; you will only need one instance of class MongoClient even with multiple threads.
MongoDriver jest już na tyle dojrzałym kawałkiem softu, że robi to za nas. Dlatego możemy w naszej aplikacji korzystać z pojedynczego, globalnego obiektu. Fajnie co nie? :)
Definiowanie struktury przechowywanego dokumentu
Idąc zgodnie z duchem .NET-owskich ORM-ów, biblioteka mongo driver dodaje nam kilka customowych atrybutów, dzięki którym możemy odpowiednio oznaczyć propertisy w naszych obiektach POCO. Przykładowa definicja takiego obiektu wygląda następująco:
public class User
{
[BsonId]
[BsonElement("id")]
[BsonRepresentation(BsonType.String)]
public Guid Id { get; set; }
[BsonElement("name")]
public string Name { get; set; }
[BsonElement("email")]
public string Email { get; set; }
[BsonElement("password")]
public string Password { get; set; }
}
Jak widać użyłem kilka atrybutów odpowiadających za:
- BsonId - oznaczenie klucza głównego
- BsonElement - nazwę parametru, w którym będzie zapisana wartość tego pola
- BsonRepresentation(BsonType.String) - typ wartości zapisanej do bazy danych. Jeżeli w aplikacji używamy typu Guid, to aby otrzymać jego reprezentacje w bazie danych, w jakiejś czytelnej formie, musimy dodać ten atrybut. W przeciwnym wypadku zamiast Guid-a otrzymamy coś w stylu: new BidData("hdkjashdksajdsakdask==", 3)
Dodanie dokumentu do kolekcji
Mając już utworzonego klienta bazy danych i zdefiniowaną strukturę dokumentu możemy przejść do dodania nowo utworzonego obiektu klasy User do naszej kolekcji Users. Kod wygląda następująco:
var model = new User
{
Id = Guid.NewGuid,
Name = "bd90",
Email = "test@test.it",
Password = "pass1332!"
}
var collectionName = "Users";
var database = client.GetDatabase("mydb");
var collection = database.GetCollection<User>(collectionName);
await collection.InsertOneAsync(model);
Najpierw tworzymy nową instancję klasy User. Następnie definiujemy nazwę kolekcji, w której chcemy przechować dokument. Później, do zmiennej, przypisujemy połączenie z odpowiednią bazą danych (w tym przypadku "mydb"). Od tego momentu, jedyne co nam pozostało, to pobranie kolekcji Users za pomocą database.GetCollection(collectionName) i wstawienie nowego dokumentu za pomocą asynchronicznego wywołania metody InsertOneAsync. Gotowe! Nowy dokument powinien pokazać się w bazie danych :)
Na tym momencie mógłbym już przestać pisać ten artykułu. Nie wiem czy uwierzycie, że to koniec, widząc ścianę tekstu tam dalej. Może to dla ściemy napisałem i reszta to zbitek nic niewnoszącego tekstu? Nie no dobra, dalszy tekst ma sens i polecam go przeczytać. Chciałbym wam pokazać jeszcze jedną fajną rzecz. Mianowicie, powyższy kod ma jedną małą wadę. Bez obsługi typów generycznych skończylibyśmy z dość sporą duplikacją kodu, co nie jest zbyt fajnym zjawiskiem. Natomiast, jeśli spróbujemy stworzyć generyczne repozytorium, to bardzo zaboli nas potrzeba ustawiania nazwy kolekcji, do której chcemy się odwoływać. Jeżeli Ci interesuje jak ja podszedłem do tego tematu zapraszam do dalszej lektury.
Tworzymy generyczne Repozytorium
Pierwszy krok do rozwiązania: zacznijmy od zdefiniowania repozytorium, do którego będzie dążyć z naszą implementacją. W celach przykładowych, repozytorium będzie zawierać tylko implementacje dodawania nowego elementu do kolekcji.
public interface IMongoDbRepository<T> where T : class
{
Task InsertOne(T model);
}
public class MongoDbRepository<T> : IMongoDbRepository where T : class
{
public IMongoDatabase Database { get; }
public MongoDbRepository(IMongoClient client)
{
Database = client.GetDatabase("mydb");
}
public async Task InsertOne(T model)
{
var collectionName = GetCollectionName();
var collection = Database.GetCollection<T>(collectionName);
await collection.InsertOneAsync(model);
}
private static string GetCollectionName() { ... }
}
Jak widać implementacja metody InsertOne jest niemal identyczna jak kod do wstawienia pojedynczego elementu "na sztywno". Różnice to użycie generycznego typu atrybutu T, który musi zostać zdefiniowany na poziomie klasy, i dodanie magicznej metody "GetCollectionName" zwracającej nam nazwy kolekcji dla odpowiedniego typu.
Skąd metoda ma pobrać nazwę kolekcji? Uznałem, że najlepszym wyjściem będzie stworzenie własnego atrybutu klasy pod nazwą (BsonCollection). Jego implementacja jest banalnie prosta.
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class BsonCollectionAttribute : Attribute
{
private string _collectionName;
public BsonCollectionAttribute(string collectionName)
{
_collectionName = collectionName;
}
public string CollectionName => _collectionName;
}
Wystarczy zwykła klasa dziedzicząca po klasie "Attribute", która będzie zawierała jeden parametr w konstruktorze. Parametrem oczywiście będzie nazwa naszej kolekcji.
Teraz możemy ten stworzony atrybut dodać do naszego obiektu POCO.
[BsonCollection("Users")]
public class User
{
[BsonId]
[BsonElement("id")]
[BsonRepresentation(BsonType.String)]
public Guid Id { get; set; }
[BsonElement("name")]
public string Name { get; set; }
[BsonElement("email")]
public string Email { get; set; }
[BsonElement("password")]
public string Password { get; set; }
}
Za pomocą mechanizmu reflekcji możemy skończyć implementację naszej metody GetCollectionName, która z generycznego typu T wyłuska nam atrybuty o typie BsonCollectionAttribute.
private static string GetCollectionName()
{
return (typeof(T).GetCustomAttributes(typeof(BsonCollectionAttribute), true).FirstOrDefault()
as BsonCollectionAttribute).CollectionName;
}
W ten sposób otrzymujemy generyczne repozytorium MongoDB :)
Podsumowanie
Mam nadzieje, że nieco przybliżyłem wam jak korzystać z bazy MongoDB w świecie .NET Core. Jak zwykle mam nadzieje, że artykuł się przydał. Mimo ostatniej stagnacji planuje ruszyć się do działania. Mam nadzieje, że znów zacznę regularnie pisać na bloga :)
Dzięki wszystkim za poświęcony czas.
Do Następnego!
Cześć :)