data.mdx.frontmatter.hero_image

Azure Search .NET SDK

2018-12-06 | Azure, .NET | bd90

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ść!

By Bd90 | 06-12-2018 | Azure, .NET