Znacie ten typ ludzi, którzy z całych sił próbują naprawić popełniony błąd i chcą zjeść pączka średnio raz na 3 dni? To nie ja. Ja wole jeść pączki częściej. A co do poprawy - obiecałem, że będzie więcej artykułów dotyczące Azure? No i to kolejny artykuł o usłudze Azure Search.
Kto by pomyślał że powstanie z tego taka mała seria artykułów? Na pewno nie ja. Bardzo dziękuje za odzew dotyczący tych wpisów. Motywuje mnie to do dalszej, intensywnej pracy na blogiem.
W poprzednich artykułach skupiłem się na działaniu samej usługi i możliwościach oferowanych przez portal Azure. Powiedzmy sobie szczerze - prawdziwych programistów jak wy nie interesuje co można "wyklinać" w jakimś tam GUI, prawda? My potrzebujemy kodu, mięcha! Dlatego chciałbym zabrać was w małą wycieczkę po Azure Search .NET SDK.
Dodanie SDK do projektu .NET Core
Nie chcąc mieszać wam w głowach nadal pozostaniemy w kontekście książek. Napiszemy prostą aplikację konsolową, która będzie dodawać i przeszukiwać dokumenty w index-ie. Wykorzystamy do tego paczkę Microsoft.Azure.Search
. Zapewne wiecie jak, za pomocą dotnet CLI, dodać ją do projektu, jednak jakby ktoś z was zapomniał to wstawiam gotowy kod :)
$ dotnet add package Microsoft.Azure.Search -v 5.0.0
$ dotnet restore
Wersja biblioteki jest na sztywno, ponieważ z wersją 5.0.3 miałem problemy na swoim komputerze. Rzucało mi dziwnym błędem związanym z klientem HTTP. Zakładam, że dość szybko zostanie to naprawione, na ten moment muszę was przed tym ostrzec ;)
Konfiguracja Azure Search .NET SDK
Jak to zazwyczaj bywa z usługami w chmurach musimy mieć jakąś konfigurację pozwalającą na odpytywanie się usługi. Nie inaczej jest z Azure Search. Tutaj potrzebujemy trzech rzeczy:
- Nazwę indeksu, do którego mają być kierowane zapytania
- Nazwę usługi Search, do której mają być kierowane zapytania
- Klucz admin-a, który będzie pozwał na dostęp do usługi
Jako, że piszę aplikację w C# uruchamianą po stronie serwer-a możemy użyć klucza admina. W przypadku chęci tworzenia indeksów, aby dodawać nowe dokumenty, nawet musimy. Jeżeli zachowamy zasady bezpieczeństwa to wszystko powinno być proste i bez kłopotów. W końcu nie będzie przechowywać tak ważnego klucza po stronie aplikacji klienckiej.
Osobiście zawsze lubię zamykać takie obiekty konfiguracyjne w osobne klasy. Wtedy mogę w prosty sposób w ramach development-u wpisać na sztywno wartości. A zarazem jak aplikacja trafia na produkcję to bardzo łatwo za pomocą jakiegoś systemu CD uzupełnić tą klasę produkcyjnymi wartościami.
No to stwórzmy konfigurację. Nazwijmy ją tak tajemniczo AzureSearchConfig
. Zakładając. że nasza konfiguracja jest niezmiennikiem, możemy ją napisać w poniższy sposób.
public class AzureSearchConfig
{
public string IndexName { get; }
public string SearchName { get; }
public string SearchKey { get; }
public AzureSearchConfig(string indexName, string searchName, string searchKey)
{
IndexName = indexName;
SearchName = searchName;
SearchKey = searchKey;
}
}
Encja i dokument
Mamy już konfigurację. Teraz potrzebujemy klas, które będą odpowiadały naszym obiektom domenowym i dokumentom w kolekcji Azure Search. Tak jak pisałem wcześniej, nadal pozostaniemy w kontekście książek, więc nasza przykładowa encja mogłaby wyglądać następująco:
public class Author
{
public string First { get; set; }
public string Last { get; set; }
}
public class Book
{
public string Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public IEnumerable<string> Tags { get; set; }
public Author Author { get; set; }
}
Nie ma tu nic odkrywczego. Jest jedno utrudnienie, o którym pisałem już w artykule Azure Search Spłaszczanie Struktur Danych. Mamy zagnieżdżony obiekt, z którym usługa Azure Search jeszcze sobie nie poradzi. Dlatego potrzebujemy stworzyć obiekt DTO, który będzie miał spłaszczone pole Author
.
public class BookDTO
{
public string Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public IEnumerable<string> Tags { get; set; }
public string Author_First { get; set; }
public string Author_Last { get; set; }
}
Potrzebujemy jeszcze mechanizmu mapowania pomiędzy tymi dwoma klasami. W prawdziwym projekcie prawdopodobnie użyłbym biblioteki AutoMapper, jednak tutaj wprowadziłoby to więcej zamieszania niż pożytku. Na szybko możemy napisać następującego helper-a.
public static class Mapper
{
public static BookDTO BookToBookDTO(Book book)
{
return new BookDTO
{
Id = book.Id,
Title = book.Title,
Description = book.Description,
Tags = book.Tags,
Author_First = book.Author.First,
Author_Last = book.Author.Last
};
}
public static Book BookDTOToBook(BookDTO bookDto)
{
return new Book
{
Id = bookDto.Id,
Title = bookDto.Title,
Description = bookDto.Description,
Tags = bookDto.Tags,
Author = new Author
{
First = bookDto.Author_First,
Last = bookDto.Author_Last
}
};
}
}
Jeszcze tylko nieśmiertelne InMemoryRepository
i cały kod związany z naszymi encjami mamy ogarnięty.
public class BookRepository
{
private IEnumerable<Book> _list = new List<Book>
{
new Book
{
Id = Guid.NewGuid().ToString(), Title = "Ogniem i mieczem", Description = "Lorem lipsum dolor",
Tags = new List<string> { "dobre bo polskie", "ogniem", "mieczem" }, Author = new Author
{
First = "Henryk",
Last = "Sienkiewicz"
}
},
new Book
{
Id = Guid.NewGuid().ToString(), Title = "Krzyżacy", Description = "Lorem lorem sit amend",
Tags = new List<string> { "test", "krzyzacy" }, Author = new Author
{
First = "Henryk",
Last = "Sienkiewicz"
}
},
new Book
{
Id = Guid.NewGuid().ToString(), Title = "Pan Tadeusz", Description = "Lorem lorem",
Tags = new List<string> { "test", "pan tadeusz" }, Author = new Author
{
First = "Adam",
Last = "Mickiewicz"
}
}
};
public IEnumerable<Book> GetAll()
{
return _list;
}
}
Stworzenie serwisu do komunikacji z Azure Search
Mamy nasz obiekt konfiguracyjny i wszystkie potrzebne encję. Czas przejść do stworzenia serwisu, który będzie nam enkapsulował komunikację z usługą Azure Search. Super! W końcu będzie więcej mięcha ;) Robimy to, aby zachować wywołania do Azure Search-a w jednym miejscu aplikacji.
Nadamy naszemu serwisowi tajemniczą nazwę AzureSearchService
. Jako zależność w konstruktorze przekażemy mu obiekt konfiguracyjny.
public class AzureSearchService
{
private readonly AzureSearchConfig _config;
public AzureSearchService(AzureSearchConfig config)
{
_config = config;
}
}
Pora przejść do wykorzystania Azure Serach .NET SDK. Tworzymy dwa obiekty: klienta serwisu i klienta index-u. Jaka jest różnica pomiędzy tymi obiektami? Za pomocą klienta serwisu możemy zarządzać indeksami, zaś za pomocą klienta index-u możemy zarządzać dokumentami w kolekcji.
Obie instancję przypiszmy do klasy jako propertis-y.
public class AzureSearchService
{
private readonly AzureSearchConfig _config;
private ISearchServiceClient _searchServiceClient;
private ISearchIndexClient _searchIndexClient;
public AzureSearchService(AzureSearchConfig config)
{
_config = config;
_searchServiceClient = new SearchServiceClient(_config.SearchName, new SearchCredentials(_config.SearchKey));
_searchIndexClient = _searchServiceClient.Indexes.GetClient(_config.IndexName);
}
}
Uzupełnianie serwisu metodami
Mam nadzieje, że wszystko do tego momentu jest czytelne i zrozumiałe(na pewno suuuper ciekawe i standardowo gratuluje wszystkim, którzy mają siłę i chęci :) Idąc dalej - potrzebujemy jeszcze trzech metod:
- Tworzenia indeksu,
- Dodawania dokumentów do indeksu,
- Przeszukiwanie indeksu
Na szczęście Azure Search .NET SDK posiada przyjemne w użytkowaniu API. Wystarczy kilka linijek by wszystko działało. W pierwszej kolejności zajmijmy się tworzeniem indeksu z poziomu kodu. Tworzy się go za pomocą klasy IndexModel zawartej w paczce Microsoft.Azure.Search
. Unikając tworzenia silnych wiązań pomiędzy serwisem a indexem, przyjmijmy taki obiekt w parametrach wywołania tej metody. Samą klasę IndexModel omówimy sobie w dalszej części artykułu. Dodatkowo, rzecz jasna, musimy jeszcze sprawdzić czy podany index nie jest już utworzony w usłudze. Cały kod wygląda następująco:
public void CreateIndex(Index indexModel)
{
if (_searchServiceClient.Indexes.Exists(_config.IndexName))
{
return;
}
_searchServiceClient.Indexes.Create(indexModel);
}
Przyszła pora by przejść do generycznej metody, która pozwoli na dodawanie dokumentów do index-u. Z pomocą Azure Search .NET SDK możemy dodać naraz aż do 1000 dokumentów. Oczywiście, im więcej będzie dokumentów w jednym batch-u, tym dłużej będzie trwało zindeksowanie wszystkich elementów.
public void Add<T>(IEnumerable<T> documents) where T : class
{
_searchIndexClient.Documents.Index(IndexBatch.MergeOrUpload(documents));
}
Cała metoda prezentuje się jak powyżej. Wszystkie dokumenty są wgrywane. Jeżeli podany dokument był już w kolekcji, zostaje nadpisany dzięki wykorzystaniu metody MergeOrUpload
. Chcecie poczytać więcej na ten temat? Polecam mój artykuł na temat Azure Search REST API, gdzie znajdziecie opisane wszystkie typy zapytań.
Pozostaje nam trzecia, i na obecną chwilę ostatnia, metoda do napisania serwisu - przeszukiwanie kolekcji. Jak się pewnie domyślacie Azure Search pozwala nam parametryzować wyszukiwanie za pomocą kilku propertis-ów takich jak OrderBy
, Skip
, Top
. Metoda będzie przyjmować, oprócz samej szukanej frazy query
,obiekt klasy SearchParameters
. O obiekcie, tak samo jak w przypadku IndexModel,
napiszę więcej w późniejszej części artykułu.
public DocumentSearchResult<T> Search<T>(string query, SearchParameters sp) where T : class {
return _searchIndexClient.Documents.Search<T>(query, sp);
}
Oczywiście ta metoda będzie zwracać wyniki :) Wszystko co usługa nam zwróci Azure Search .NET SDK zamyka nam w klasie DocumentSearchResult
.
Wykorzystanie Serwisu
Tworzenie Index-u
Koniec przygotowań! Zacznijmy korzystać z nowo stworzonego serwisu. Stwórzmy prosty index odzwierciadlający nasz obiekt bookDto. Będziemy potrzebowali wspomnianej wcześniej klasy IndexModel
.
var indexModel = new Index
{
Name = config.IndexName,
Fields = new List<Field>
{
new Field("Id", DataType.String)
{
IsKey = true, IsRetrievable = true, IsSearchable = true, IsSortable = true
},
new Field("Title", DataType.String)
{
IsRetrievable = true, IsSearchable = true, IsSortable = true
},
new Field("Description", DataType.String)
{
IsRetrievable = true, IsSearchable = true, IsSortable = true
},
new Field("Author_First", DataType.String)
{
IsRetrievable = true, IsSearchable = true, IsSortable = true
},
new Field("Author_Last", DataType.String)
{
IsRetrievable = true, IsSearchable = true, IsSortable = true
},
new Field("Tags", DataType.Collection(DataType.String))
{
IsRetrievable = true, IsSearchable = true, IsFilterable = true, IsFacetable = true
}
}
};
service.CreateIndex(indexModel);
Najprostsza implementacja składa się z dwóch elementów: nazwy index-u i listy pól, jakie ma zawierać. Oczywiście sam IndexModel
ma więcej możliwych ustawień, ale to temat na kolejne artykuły. Każde pole składa się z nazwy, typu danych i flag, którymi definiujemy ustawienia dla Pola. Potrzebujesz więcej informacji na temat flag? Zapraszam do przeczytania mojego artykułu Azure Search Wyszukiwarka w Mgnieniu Oka, gdzie jest to skrupulatnie opisane. Na koniec przykładu wywołujemy stworzony wcześniej serwis, który utworzy nam nowy index w usłudze.
Dodawanie dokumentów do kolekcji
Pamiętacie jeszcze te wszystkie encję, dto, repozytoria stworzone na początku artykułu? Wykorzystajmy to, by dodać dokumenty do naszej kolekcji. Wystarczy pobrać wszystkie elementy z naszego repozytorium, zrobić mapowanie Book -> BookDTO i możemy je zindeksować!
var documents = new BookRepository().GetAll().Select(x => Mapper.BookToBookDTO(x)).ToList();
service.Add(documents);
Szybki podgląd w portalu Azure czy to zadziałało:
Działa! Super :)
Przeszukiwanie kolekcji dokumentów
Skoro mamy już świeżo dodane dokumenty do naszego index-u, możemy przejść do przeszukiwania. W tym celu wykorzystamy ostatnią metodę zdefiniowaną w naszym serwisie. Pobranie wszystkich elementów wymaga zapytania:
var response = service.Search<BookDTO>("\*", new SearchParameters());
IEnumerable<Book> books = response.Results.Select(x => Mapper.BookDTOToBook(x.Document)).ToList();
Oczywiście, po pobraniu elementów, musimy je przemapować na nasz obiekt domenowy. W tym przypadku się to trochę komplikuje, ponieważ Azure Search dostarcza nam obiekt BookDTO, opakowany w ich klasy takie jak DocumentSearchResult
i SearchResult
. Nadal można to ogarnąć za pomocą naszego Mapper-a i jednej linijki LINQ. Abyście mogli zobaczyć jak to wygląda na żywo, załączam wam screen z Rider-a.
W tym zapytaniu pojawiła się po raz kolejny klasa SearchParameters
, o której jeszcze nie powiedziałem ani słowa. Służy do definiowania parametrów wyszukiwania i jest nam dostarczona przez paczkę Microsoft.Azure.Search
. Pozwala nam nie tylko na przeszukiwanie, kategoryzację czy filtrowanie wyników ale też, za pomocą metod Skip
i Top,
na paginację wyników.
Jeżeli, dla przykładu, potrzebowalibyśmy wyszukać wszystkie książki oznaczone tagiem test
i posortować je po imieniu autora, to moglibyśmy skonstruować następujące zapytanie:
var response = service.Search<BookDTO>("*", new SearchParameters
{
OrderBy = new List<string> { "Author_First asc" },
Filter = "Tags/any(t: t eq 'test')"
});
Wygląda na skomplikowane? Chyba nie :) A jeśli nawet - nie bójcie się, przejdziemy przez te parametry w kolejnych artykułach na temat tej usługi :)
Podsumowanie
Nie wiem dlaczego te artykułu na temat Azure Search-a, tak bardzo mi się rozrastają. Wyszło jak wyszło :P Mam nadzieje, że nie macie mi za złe, chęć przekazania wiedzy silniejsza od logiki. Za tydzień możecie się spodziewać kolejnego artykułu o Azure :) Jeżeli chcecie być zawsze na bieżąco - zapiszcie się na newsletter. Można to uczynić na dole strony. Tak, wiem że ten formularz nie wygląda zbyt zachęcająco, niedługo to się zmieni. Maile są ładniejsze, zapewniam :)
Jak zwykle dzięki za przeczytanie artykułu. Do Następnego!
Cześć!