You are currently viewing Azure Functions v4. Perypetie z Table storage

Azure Functions v4. Perypetie z Table storage

Jednym z rodzajów składnicy danych na platformie Azure jest Azure Table storage. Tabele tworzone bezpośrednio w ramach Storage Account są dość prostymi konstrukcjami (są też inne nazywane Azure Cosmos DB for Table, ale tymi się nie tu nie zajmujemy). Nie oferują zbyt wiele, jeśli chodzi o tworzenie zapytań, nie mają schematu i dysponują tylko jednym indeksem, no i nie pozwalają na tworzenie relacji. Mają za to bardzo mocny atut: są tanie. Oprócz tego szybkie i pojemne (do pojemności konta storage). Jeśli Twoje funkcje zadowolą się tabelami, to skorzystanie z Table storage będzie na pewno z korzyścią dla OPEXu projektu.

W tym wpisie omówimy sobie przykład api z wykorzystaniem funkcji. Api będzie zawierało CRUD dla listy zadań z persystencją w Azure Table Storage. W ten sposób będzie trochę łatwiej zorientować się, jaki jest efekt działania funkcji, bo dostaniemy odpowiedź ze statusem żądania.

Azure Table storage

Tabele są słownikami z kilkoma polami predefiniowanymi. Widać je w interfejsie ITableEntity

namespace Azure.Data.Tables
{
    public interface ITableEntity
    {
        string PartitionKey { get; set; }
        string RowKey { get; set; }
        DateTimeOffset? Timestamp { get; set; }
        ETag ETag { get; set; }
    }
}

Timestamp jest czasem aktualizacji rekordu (UTC) aktualizowanym przez Azure. ETag ma zastosowanie do optimistic concurrency. Nie interesują nas one teraz. Pola PartitionKey i RowKey stanowią klucz composite key naszego rekordu. Na tym kluczu jest automatycznie założony indeks.
Ważne dla nas dane użytkownika znajdują się w dodatkowych polach w postaci klucz:wartość. Załóżmy, że funkcja ma odkładać w Table storage listę zadań. Nasza encja na potrzeby Table storage będzie wyglądała tak, jak klasa TodoTableEntity. W każdej chwili możemy zmodyfikować tę encję i np. dodać pole. Przy zapisie zmodyfikowanej encji w table storage zostanie ono dodane, przy czym w istniejących już wierszach wartością będzie null. Tak się objawia termin schema less, którym określa się Table storage.

Własny model danych

We wcześniejszej wersji funkcji i bibliotek do realizacji Table storage bindings można było korzystać z typu TableEntity i z niego wywodzić typy pochodne. Najpierw typ zniknął z biblioteki Microsoft.Azure.Functions.Worker.Extensions.Storage, a potem dostaliśmy uproszczony interfejs w bibliotece Microsoft.Azure.Functions.Worker.Extensions.Tables. Warto utworzyć sobie abstakcyjną klasą bazowę implementującą ten interfejs, a z niej wywodzić konkretne klasy dla naszych danych.

using Azure.Data.Tables;

public abstract class BaseTableEntity : ITableEntity
{
    public string PartitionKey { get; set; } = null!;
    public string RowKey { get; set; } = null!;
    public DateTimeOffset? Timestamp { get; set; }
    public ETag ETag { get; set; }
}

public class TodoTableEntity : BaseTableEntity
{
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public string Name { get; set; } = null!;
    public string? Description { get; set; }
    public bool IsCompleted { get; set; }
}

Rozdział hosta z funkcjami od runtime

Funkcje mogą być uruchamiane w tym samym procesie, co runtime (in-process), a od NET6 także w procesie workera hostowanym w aplikacji ASP.NET Core albo zwykłej aplikacji NET Core. Taka odmiana nazywa się isolated i ma tę m.in zaletę, że można korzystać z kontenera DI. Aplikację po prostu konfiguruje się jak każdą aplikację ASP.NET/NET Core. Dla naszego przykładu użyjemy frameworku ASP.NET Core i odmiany isolated.Trzeba się z trybem isolated zaznajomić tym bardziej, że odmiana in-process traci wsparcie w listopadzie 2026r.

Wiązanie encji z tabelą – binding

Encja TodoTableEntity implementująca ITableEntity będzie automagicznie zawierała interesujące nas dane, kiedy tylko będziemy chcieli do nich sięgnąć. Przy odczycie (input binding) zwróci encję zadania. Albo sama „się zapisze” do tabeli, jeśli ewentualnie przyjdzie nam do głowy zapisać nowe zadanie (output binding). Za magię odpowiedzialny jest binding. Robi się go prosto w teorii prosto. Czy tak jest zobaczymy omawiając CRUD.

Dodanie wiersza

Poniżej jest kod poglądowy, jak mogłoby wyglądać utworzenie nowego zadania.

[Function("CreateTodo")]
public TodoTableEntity Add(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "todo")] HttpRequest request),
    [TableOutput("TableName", Connection = "ConnectionString")])
{
    // deserialise request body and map json to entity
    return TodoTableEntity
}

Atrybut HttpTriggerAttribute mapuje żądania post:http\\myhost\api\todo. Uruchamiana jest funckja CreateTodo. Funkcja wykonuje zadanie utworzenia encji a return wywołuje sekwencję zdefiniowaną przez binding. Ten jest zdefiniowany przez TableOutputAttribute. W ten sposób encja powinna zostać zapisana w tabeli (pamiętamy, że TodoTableEntity implementuje ITableEntity) . Założenie jest takie, że bindings są abstrakcją uwalniającą programistę od szczegółów warstwy persystencji.

W funkcji CreateTodo triggerem jest żądanie http. To powoduje niejawny binding HttpResponse, żeby po żądaniu mogła nadejść odpowiedź. Mamy wobec tego dwa bindingi: tabela oraz odpowiedź http. Oba oczekują innych modeli. Rozwiązano to w następujący sposób: tworzy się klasę, w której parametry o typie oczekiwanym przez binding, są udekorowane atrybutem, np TableOutputAttribute. Binding typu HttpResponse, jak wspomniałem wcześniej, nie wymaga atrybutu. Binding do tabeli ma też tę miłą właściwość, że nie rzuca błędem, kiedy dostaje null zamiast instancji modelu. To pozwala odesłać status błędu http bez powodowania problemów ze strony Table storage.

public class TodoResponseDTO(TodoTableEntity? entity, HttpResponse response)
{
    [TableOutput(TodoApi.TableName, Connection = "AzureWebJobsStorage")]
    public TodoTableEntity? Entity { get; } = entity;

    public HttpResponse Response { get; } = response;
}

W kodzie widać odwołanie do statycznej metody mapującej modele AsTableEntity(). Jest to rozwiązane standardowo, a cały kod można obejrzeć klikając w link na końcu artykułu. Ostatecznie funkcja CreateTodo wygląda następująco

[Function("CreateTodo")]
public async Task<TodoResponseDTO> Add(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "todo")] HttpRequest req)
{
    _logger.LogInformation("Create a todo");

    var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
    var response = req.HttpContext.Response;

    try
    {
        var data = JsonSerializer.Deserialize<TodoCreateDTO>(requestBody);
        var todo = new Todo { Name = data!.Name };
        response.StatusCode = StatusCodes.Status200OK;
        await response.WriteAsJsonAsync(todo);
        return new TodoResponseDTO(todo.AsTableEntity(), response);
    }
    catch
    {
        response.StatusCode = StatusCodes.Status400BadRequest;
        return new TodoResponseDTO(null, response);
    }
}

W ten sposób mamy rozwiązany temat utworzenia zadania w naszym przykładowym api. Nowe zadanie jest zapisanie w Azure Table storage.

Odczyt pojedynczego zadania z Table storage

Tu także mamy możliwość zastosowania wiązania. W założeniu żądanie get:http://myhost/api/todo/bcbdc435bc2f43449c2404ec03b0d7de powinno wywołać obsługę wiązania i w efekcie zwrócić encję zadania. Cała funkcja powinna wyglądać tak:

[Function("GetTodoById")]
public async Task<IActionResult> GetById(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todo/{id}")] HttpRequest req,
    [TableInput("TableName", PartitionKey, "{id}", Connection = "ConnectionString")]  TodoTableEntity todo)
{
    if(todo is not null)
        return new OkObjectResult(existingRow.AsTodo());
    
    return new NotFoundResult();
}

Żeby jednak nie było zbyt łatwo, obsługa tego wiązania zawiera błąd, który społeczność zgłaszała kilka miesięcy temu (było otwieranych kilka issues). Na połowę maja 2024r. błąd nadal występuje. Można zastosować trik, który ma pewną wadę – zwraca wszystkie wiersze, które możemy sobie przefiltrować, ale raczej tego nie chcesz.

Dygresja na temat kosztów

Pamiętaj, że najkorzystniejszy cenowo model hostowania funkcji to consumption model. Płacimy za zużycie, które jest liczone jako iloczyn czasu wykonywania funkcji oraz użytej pamięci (funkcje same się skalują w krokach 128MB). Jeśli przy każdym odczycie zadania pociągniesz ich tysiąc – nie ma problemu. Ale jeśli pociągniesz bazę 10 mln użytkowników, to może zaboleć.

Dlatego nie zastosujemy wiązania do encji, tylko wykorzystamy je do otrzymania obiektu TableClient, który pozwala pracować bezpośrednio z SDK. Zwróć uwagę, że klient rzuci błędem, jeśli wiersz nie zostanie odnaleziony. Ten błąd trzeba obsłużyć, żeby zwrócić status 400. Jeśli tego nie zrobisz, to funkcja zwróci status 500, któy nie odzwierciedli tego, co zaszło. W przykładzie nie obsługuję innych błędów niż RequestFailedException klienta

[Function("GetTodoById")]
public async Task<IActionResult> GetById(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todo/{id}")] HttpRequest req,
    [TableInput(TableName, PartitionKey, "{id}", Connection = "AzureWebJobsStorage")] TableClient todoTable,
    string id)
{
    _logger.LogInformation("Getting todo by id {Id}", id);

    TodoTableEntity existingRow;
    try
    {
        var findResult = await todoTable.GetEntityAsync<TodoTableEntity>(PartitionKey, id);
        existingRow = findResult.Value;
    }
    catch (RequestFailedException e) when (e.Status == 404)
    {
        return new NotFoundResult();
    }
    return new OkObjectResult(existingRow.AsTodo());
}

Odczyt wszyskich zadań

W kontekście dygresji o kosztach sam tytuł jest prowokacyjny. Oczywiście, w przypadku większych zbiorów danych nie powinniśmy wogóle doprowadzać do sytuacji ładowania całości do pamięci. Do dyspozycji mamy na szczęście funkcję rozszerzającą AsPages(), która pozwala zdefiniować wielkość strony (domyślnie jest to 1000). Niestety nie ma gwarancji, że wszystkie rodzaje wiązań obsłużą ten parametr. Tabele obsługują. Czyli można zaimplementować paginację zamiast zaciągania wszysktiego. Ja na potrzeby przykładu i pokazania wiązania do Table storage pomijam ten aspekt.

[Function("GetTodos")]
public async Task<IActionResult> Get(
    [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "todo")] HttpRequest req,
    [TableInput(TableName, Connection = "AzureWebJobsStorage")] TableClient todoTable)
{
    var todosCount = await todoTable.QueryAsync<TodoTableEntity>().CountAsync();
    _logger.LogInformation("Getting todos. There is {Count} entries.", todosCount);

    // Be careful, it loads all items into memory.
    // QueryAsync<TodoTableEntity>().AsPages().... could be used
    var todos = await todoTable.QueryAsync<TodoTableEntity>().ToArrayAsync();
    return new OkObjectResult(todos.Select(Mappings.AsTodo));
}

Pozostałe operacje CRUD

Pozostały do zaprezentowania literki U i D – aktualizacja i usunięcie zadania. W konteście wiązania z Table storage nie ma tu nic nowego ponad to, co już przedstawiłem. Trzeba skorzyskać z SDK i obiektu TableClient. Ma on odpowiednie metody UpdateEntity() i DeleteEntity() oraz ich wersje asynchroniczne.

Przy aktualizacji możemy połączyć pola (TableUpdateMode.Update) lub zastąpić (TableUpdateMode.Replace). W naszym przypadku właściwe jest zastosowanie TableUpdateMode.Replace. Przekazuję ETag encji odczytanej z tabeli. Jeśli okaże się, że pomiędzy odczytem a zapisem przez ten egzemplasz funkcji ktoś inny wykona aktualizację w inny sposób, to wystąpi błąd. Omówienie concurrency może być tematem innego artykułu.

// update
await todoTable.UpdateEntityAsync(existingRow, existingRow.ETag, TableUpdateMode.Replace);

Przy usuwaniu zadania nie interesuje nas wersja, którą usuwamy. Dlatego przekazujemy ETag.All (ostatni fragment kodu poniżej). Niezależnie od wszystkiego zadanie zostanie usunięte.

Zwróć uwagę, że przy aktualizacji i usuwaniu TableClient nie rzuci błędem, jeśli nie odnajdzie wiersza. Przy aktualizacji sprawdzam właściwość HasValue

// get entity for update
var findResult = await todoTable.GetEntityIfExistsAsync<TodoTableEntity>(PartitionKey, id);
if (!findResult.HasValue)
    return new NotFoundResult();

Natomiast przy usuwaniu sprawdzam właściwość Status. Jeśli wiersz nie istnieje, to zwracany jest status http 404.

// delete
var response = await todoTable.DeleteEntityAsync(PartitionKey, id, ETag.All);
if(response.Status == 404)
    return new NotFoundResult();

Azurite

Do działania funkcje potrzebują Azure Storage account. Przy uruchamianiu lokalnym warto użyć Azurite. Opensource’owy Azurite zastępuje Azure Storage Emulator. Udostępnia bezpłatne lokalne środowisko do testowania aplikacji korzystających z
– Azure Blob, 
– Queue Storage,
– Table Storage

Jeśli pracujesz z Visual Studio, to Azurite będzie uruchomiony razem z aplikacją. W innym przypadku trzeba ręcznie uruchomić emulator. Skąd pobrać i jak zainstalować dowiesz się ze strony Azurite emulator for local Azure Storage development

Warto jeszcze skonfigurować aplikcaję, aby korzystała z emulatora. Odpowiada za to plik local.settings.json, a dokładnie klucz AzureWebJobsStorage. Ten klucz wpisujemy jako wartość parametru Connection w wiązaniach. Przy publikacji do chmury wartością klucza będzie connection string do naszego Azure Storage account.

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
  }
}

Podsumowanie

Przedstawiłem, jak można wykorzystać tani Azure Table storage do przechowywania danych i jak te dane przekazywać do i z funkcji. Aplikację z funkcjami przedstawiłem w modelu isolated, gdzie są one uruchamiane jako osobny proces. W przyszłości zamierzam zrobić wpis na temat Durable Functions. W tym wcieleniu funkcje są uruchamiane nie tylko przez triggery, ale przez dodatkową funkcję – orkiestrator. To ciekawe rozwiązanie ułatwia kolejkowanie i uruchamianie funkcji równolegle. Zaczyna to przypominać mikroserwisy.

Jeśli chcesz sprawdzić, jak działa omawiany kod, to działająca solucja jest do pobrania z repo: https://github.com/madameczek/todoapi

Dodaj komentarz