MongoDB – jak zacząć w .NET?
MongoDB – jeden z najpopularniejszych przedstawicieli baz danych gatunku NoSQL ? Większości programistom nie trzeba go raczej przedstawiać. Jeżeli słyszałeś o NoSQL to prawie na pewno słyszałeś też tą nazwę. Ten post jednak nie będzie o tłumaczeniu, czym jest ten magiczny typ przechowywania danych. Zakładam, że skoro czytasz ten artykuł - to już to wiesz ?, a jeżeli chcesz się dowiedzieć więcej, to odsyłam Cię do obszernej dokumentacji.
Jak rozpocząć pracę z MongoDB?
Zanim rozpoczniesz pracę z MongoDB, warto zadać sobie pytanie, czy na pewno potrzebujesz tego Mongo lub innego nierelacyjnego typu bazy danych, albo czy nie jest to wynik wszechobecnego hype’u na podobne rozwiązania. Jeżeli na drugie pytanie odpowiedziałeś/aś „NIE” to teraz możemy się zastanowić kiedy warto wykorzystać nierelacyjną bazę danych.
- Mamy pewność, że nie potrzebujemy obsługi transakcji. Co prawda MongoDB radzi sobie z transakcjami pomiędzy różnymi kolekcjami, ale możemy to traktować jako taką funkcjonalność „nice to have”. Jeżeli transakcje są dla Ciebie istotne – wybierz SQL.
- Potrzebujesz szybkiego dostępu do danych.
- Silna spójność danych nie jest dla Ciebie najistotniejsza. Znaczy to mniej więcej tyle, że dopuszczasz możliwość, iż podczas odczytu nie wszystkie dane, które zostały zapisane, będą dostępne od razu.
- Istotny jest dla Ciebie czas dostarczenia aplikacji na produkcję.
- Zakładasz często zmieniający się schemat danych w trakcie developmentu.
- Potrzebujesz przetwarzać duże ilości danych, czyli oczekujesz łatwego skalowania horyzontalnego.
- Nie masz pewności co do ostatecznej struktury danych lub część danych nigdy nie będzie ustrukturyzowana.
- Chcesz zbudować aplikację w oparciu o mapę – tutaj zapraszam do mojego artykułu, który napisałem niedawno na swoim blogu.
Jeżeli którykolwiek z powyższych punktów odpowiada Twoim wymaganiom – powinieneś/powinnaś rozważyć wykorzystanie MongoDB.
Jak skonfigurować MongoDB w .NET?
Zacznijmy od tego, że mamy kilka opcji. Możemy zainstalować MongoDB lokalnie, pobrać obraz Dockerowy i uruchomić, albo skorzystać z darmowej opcji w MongoDB Atlas. Ja wybiorę tę ostatnią możliwość, ponieważ pozwoli to również na wczytanie przykładowych danych. Wykorzystując MongoDB Atlas, otrzymujemy do dyspozycji również 2 dodatkowe repliki, dzięki czemu możemy poćwiczyć shardowanie albo zapis z jednej i odczyt z drugiej.
Utworzenie bazy danych w Atlas
- W celu stworzenia bazy danych należy zalogować się na stronie https://cloud.mongodb.com/.
- W opcjach wyboru możemy utworzyć bazę w ramach AWS, Azure czy Google Cloud.
- Po utworzeniu bazy danych wybieramy opcję „Connect”.
- Dodajemy użytkownika, który będzie wykorzystywany do połączenia z bazą danych oraz dodajemy swój adres IP do listy dostępowej. (Na potrzeby developmentu, przy często zmieniającym się adresie IP, można zezwolić wszystkim adresom na połączenie do bazy – należy jednak pamiętać, aby nie umożliwiać takiego dostępu na produkcji!).
- W kolejnym kroku wybieramy sposób połączenia. Należy zaznaczyć „Connect your application” i wybrać odpowiedni Driver.
- Skopiuj connection string i zapisz sobie go gdzieś w notatniku. Przyda nam się przy konfigurowaniu połączenia w naszej aplikacji.
Konfiguracja połączenia
Pierwsze pytanie, jakie się nasuwa przy korzystaniu z bazy danych w naszej aplikacji to: „Jakiej paczki nuget użyć?”. Na naszym kodowym rynku dostępnych jest wiele rozwiązań, niemniej najlepszym podejściem jest wybór MongoDB.Driver. Jest to oficjalny klient, wspierany przez MongoDB. Używając go, możemy mieć pewność, że najnowsze funkcjonalności będą zaimplementowane. Można też znaleźć adaptacje EntityFramework’a na potrzeby MongoDB, ale nie widzę ani jednego powodu, żeby z tego korzystać. MongoDB jako nierelacyjna, dokumentowa baza danych jest skrojona i napisana idealnie pod to, aby nie używać żadnych ORM’ów. Do odczytu i zapisu danych wykorzystuje JavaScript, a więc każdy obiektowy język w bardzo prosty sposób można wykorzystać do adaptacji JavaScriptowych komend. Przejdźmy do konfiguracji.
- Zapisz wcześniej skopiowany connection string w pliku ustawień appsettings.json. Pamiętając przy tym o zamianie <username> i <password> na prawidłowe wartości.
"ConnectionStrings": {"MongoDB": "mongodb+srv://<user>:<password>@test.fd2ej.mongodb.net/mongo_test?retryWrites=true&w=majority"}
- W pliku Startup.cs rejestrujemy MongoClient jako singleton.
services.AddSingleton<IMongoClient>(new MongoClient(Configuration.GetConnectionString("MongoDB")));
Singleton w tym przypadku to jedna z dobrych praktyk przy pracy z MongoDB. Autoryzacja i autentykacja to kosztowne operacje dlatego chcemy ich uniknąć, tworząc jednego klienta dla aplikacji w celu zminimalizowania tych wywołań. Obiekt MongoClient jest bezpieczny w trakcie wielowątkowych wywołań.
Zobacz jakie to proste! Wszystko zostało skonfigurowane, teraz możemy zacząć korzystać z Mongo, wstrzykując MongoDB client! ?
Wczytanie testowych danych
Po prawej stronie w zakładce „Overview” naszego serwera dostępny jest opcja Load Sample Dataset. Wystarczy w nią kliknąć i zaczekać, aż testowe dane zostaną załadowane do naszego MongoDB Atlas.
Operacje CRUD
Stworzenie podstawowych operacji CRUD w MongoDB jest proste. Tworzymy repozytorium, wykorzystujemy klienta i gotowe. Ważne, żeby nie tworzyć nowych POCO obiektów, jeżeli nie ma ku temu wyraźnego powodu. Nie ma sensu spowalniać procesu odczytu danych tylko ze względu na mapowanie między obiektami POCO i biznesowymi. Cały mapping możemy skonfigurować za pomocą BsonClassMap – dzięki czemu unikniemy stosowania atrybutów i odseparujemy infrastrukturę od logiki biznesowej aplikacji.
Zacznę od stworzenia prostej klasy reprezentującej biznesowy obiekt „Restaurant”. Nie potrzebuję wszystkich danych, które są dostępne. Chcę tylko wiedzieć, jakie istnieją restauracje, jakie oferują kuchnie oraz w jakim mieście się znajdują.
public class Restaurant
{
public string Id { get; set; }
public string Borough { get; set; }
public string Cuisine { get; set; }
public string Name { get; set; }
public string RestaurantId { get; set; }
}
Jak widzisz, mój obiekt jest całkowicie niezależny od MongoDB, ponieważ pole „Id” jest wyrażone jako string. Nie użyłem tutaj w ogóle typu ObjectId, ponieważ chcę, aby ten został zamknięty w części odpowiedzialnej za dostęp do danych i nigdy poza nią nie wychodził.
Następnie stworzę RestaurantRepository, które będzie zawierało operacje tworzenia nowego obiektu, pobrania jednego lub wielu obiektów oraz aktualizacji/dodania nowego pola w dokumencie.
public class RestaurantsRepository : IRestaurantsRepository
{
private readonly IMongoCollection<Restaurant> collection;
public RestaurantsRepository(IMongoClient mongoClient, IOptions<DbSettings> options)
{
collection = mongoClient.GetDatabase(options.Value.DatabaseName)
.GetCollection<Restaurant>(options.Value.CollectionName);
}
public Task<Restaurant> Get(string id)
{
return collection.Find(CreateIdFilter(id)).FirstOrDefaultAsync();
}
public async Task<PagedResult<Restaurant>> Get(int pagesize, int pageindex)
{
if (pageindex < 0 || pagesize < 0)
{
throw new ArgumentException("PageIndex or PageSize cannot be less than 0");
}
var res = await collection.Find(new BsonDocument()).Skip(pagesize * pageindex).Limit(pagesize).ToListAsync();
var count = await collection.CountDocumentsAsync(new BsonDocument());
return new PagedResult<Restaurant>(res, count, pagesize, pageindex);
}
public async Task<Restaurant> Create(Restaurant restaurant)
{
await collection.InsertOneAsync(restaurant);
return restaurant;
}
public async Task CreateOrUpdateField(string id, string fieldName, object value)
{
var update = Builders<Restaurant>.Update.Set(fieldName, value);
await collection.UpdateOneAsync(CreateIdFilter(id), update);
}
private FilterDefinition<Restaurant> CreateIdFilter(string id)
{
var parsed = ObjectId.Parse(id);
return Builders<Restaurant>.Filter.Eq(x => x.Id, id);
}
}
Jak widzisz, w bardzo prosty sposób powstały te 4 metody. Wykonanie tego kodu skończy się jednak eksplozją pod tytułem FormatException ?. Aby tego uniknąć, musimy skonfigurować mapping! Istnieją dwie drogi – otagowanie klasy Restaurant atrybutami lub konfiguracja w klasie Startup. Wybierzemy tę drugą opcję, ponieważ, chcemy uniknąć sytuacji, w której MongoDb.Driver jest używany poza warstwą zarządzania danymi. W tym celu stworzę statyczną metodę, która będzie służyła mi do konfiguracji poszczególnych opcji.
BsonClassMap.RegisterClassMap<Restaurant>(opt =>
{
opt.AutoMap();
opt.GetMemberMap(c => c.RestaurantId).SetElementName("restaurant_id");
opt.GetMemberMap(c => c.Id).SetSerializer(new StringSerializer(BsonType.ObjectId));
opt.SetIgnoreExtraElements(true);
});
Metodę tą umieszczę w statycznej klasie Configuration w warstwie dostępu do danych. Została ona stworzona właśnie na potrzeby takiej konfiguracji.
Czy to jednak wszystko? Niestety nie… To co tutaj zrobiliśmy, to powiedzenie mapperowi, żeby dokonał automatycznego mapowania. Chcemy, aby wziął on również pod uwagę fakt, że pole RestaurantId w dokumencie ma nazwę „restaurant_id”. Moglibyśmy rozwiązać to, ustawiając/tworząc swoją konwencję nazewniczą i ustawiając ją za pomocą ConventionRegistry. Niemniej, ja chciałbym, aby konwencja nazewnicza w tym systemie była ustawiona jako „camelCase”. Ale o tym za chwilę. Najpierw chciałbym omówić co dzieje się dalej w naszym BsonClassMap. Wskazuję, aby nowy serializer, ustawiony dla pola Id konwertował ObjectId na typ string.
Ostatnia linia powyższego kodu ignoruje wszystkie pola dokumentu, które nie zostały dodane do klasy Restaurant. Bez tej linii MongoDB.Driver wyrzuciłby wyjątek, ponieważ w klasie docelowej nie mógłby odnaleźć tych pól.
Jak ustawić konwencje?
To dosyć prosta, ale istotna operacja. W tej samej metodzie statycznej, którą przed chwilą stworzyłem na potrzeby konfiguracji wywołuję następujący fragment kodu.
var pack = new ConventionPack();
pack.Add(new CamelCaseElementNameConvention());
ConventionRegistry.Register("Custom Convention", pack, t => t.FullName.StartsWith("MongoTest"));
Tworzę obiekt typu ConventionPack oraz dodaje do niego konwencje jakie chciałbym ustawić podczas serializacji i deserializacji. Można tutaj ustawić między innymi w jaki sposób będzie serializowany Enum – jako tekst, czy jako numer. Takich konwencji możemy tworzyć dowolną ilość. Co więcej, konwencje aplikujemy do poszczególnych typów. W moim przypadku zostały one zaaplikowane na poziomie całej przestrzeni nazw.
I to wszystko ? Nasze MongoDB zostało ustawione i gotowe do działania. ?
Dlaczego UpdateOne zamiast ReplaceOne?
W repozytorium znalazła się metoda CreateOrUpdateField. Być może zadałeś/aś sobie pytanie:
„Ale po co? Czemu nie aktualizować całego dokumentu?”
Pytanie słuszne i wydawać by się mogło, że przecież we wszystkich ORM do SQL tak robimy! W przypadku MongoDB należy konkretnie zdefiniować jakie pole chcemy dodać/usunąć/zmienić. Istnieje możliwość zastąpienia dokumentu nowym dzięki metodzie ReplaceOne, ale jest to niezalecane z kilku powodów.
Po pierwsze – wymiana całego dokumentu zajmuje więcej czasu oraz zasobów. Jest po prostu niewydajna.
Po drugie – nasza klasa „Restaurant” nie posiada kilku pól dostępnych w dokumencie. ReplaceOne doprowadziłoby do usunięcia tych pól podczas aktualizacji (wymiany dokumentu), a dane zniknęłyby bezpowrotnie, podczas gdy chciałbym zmienić tylko rodzaj kuchni oferowanych przez dany lokal.
Po trzecie – w przypadku wykorzystania operatorów na przykład inkrementacji, które są dostępne w przypadku UpdateOne, jeżeli dwa współbieżne wątki wykonują operacje na tym samym dokumencie, to obie operacje zostaną zapisane – czyli liczba inkrementowana zmieni się dwa razy. W przypadku wykorzystania ReplaceOne, jeżeli jeden wątek zmienił dokument, to zmiana przychodząca z drugiego wątku nadpisze aktualny stan danymi, które posiada dany wątek.
Dzięki wykorzystaniu UpdateOne kilka usług może wykorzystywać ten sam model dokumentu, nie usuwając pól, których akurat konkretny serwis nie używa.
Zachęcam Cię do dalszego zgłębiania świata MongoDB! ? W najbliższym czasie postaram się opisać kolejne funkcjonalności tego systemu bazodanowego, a w międzyczasie zapraszam Cię na https://university.mongodb.com/ w celu poszerzenia swojej dotychczasowej wiedzy na temat MongoDB!