C# <3 F# ?

F# i C# dobrze ze sobą współgrają w jednym projekcie. Jestem pewien, że słyszeliście to nie raz. Kompatybilność tych dwóch języków jest bardzo szeroko reklamowana. Zgadzam się, bez wątpienia jest to duża zaleta. Znacznie prościej dodać mały projekcik do istniejącego repo gdy dotychczas używany tooling pozostaje w dużej mierze taki sam. Prościej jest się również wycofać z takiej decyzji. Wejście w zupełnie nowy ekosystem obarczone jest dużo większym ryzykiem.

… ale?

Trzeba doczytać tekst małym drukiem. Wykorzystywanie prostych funkcji napisanych w F# z poziomu istniejącego kodu nie przysparza żadnych problemów. Jedna referencja do projektu a zaimportowany moduł widoczny będzie jako klasa statyczna. Problemy zaczynają się wtedy, gdy funkcje których chcemy używać przyjmują w parametrach wejściowych inne funkcje. Składanie zależności funkcji a następnie jej używanie staje się swego rodzaju wyzwaniem. Mówiąc wprost jest to zwyczajnie upierdliwe.

Gdy postanowiliśmy napisać funkcyjnie nowy kawałek w modularnym monolicie, przekonałem się o tym na własnej skórze. Projekt składał się z kilku modułów wystawionych jako API. Host był napisany w C#. Musieliśmy wywołać kod FSharpowy z poziomu controllera, czy bardziej nowocześnie, zmapowanego endpointa. Spójrzmy na przykład.

Przypadek tutorialowy

Sytuacja jest trywialna gdy funkcja, która ma zostać wywołana nie jest funkcją wyższego rzędu (higher-order function, HOF [1]).

let sampleFunc (inputStr: string) =
    inputStr.Length

Z poziomu C# widzimy następującą sygnaturę

public static int sampleFunc(string inputStr)
in class FunctionWithoutDependencies

String na wejściu, int na wyjściu. Z wywołaniem w API nie ma żadnych problemów.

app.MapGet("/SampleFun/", () => FSharpHandlers.FunctionWithoutDependencies.sampleFunc("SampleFunc"));

Przypadek prawie produkcyjny

Trudniej zaczyna się robić gdy próbujemy wywołać funkcję w bardziej produkcyjnym wydaniu. Mowa o wspomnianej funkcji wyższego rzędu. W ramach przykładu posłużę się funkcją, która zwraca pozdrowienie dla użytkownika w różnych językach.

module FSharpHandlers.SampleHandler

let getGreeting (getGreeting: string -> Async<string>) (getName: int -> Async<string>) (language: string) (clientId: int) : Async<string> =
    async {
        let! greeting = language |> getGreeting
        let! name = clientId |> getName
        return $"{greeting} {name}"
    }

Nic wyszukanego. Istotne są tylko parametry wejściowe. Mamy tu funkcje, które przyjmują typy proste i dla utrudnienia zwracają type F#. Ich implementację wyniosłem do innych modułów

module Greetings =
    let getGreeting (language: string) =
        async {
            do! Async.AwaitTask(Task.Delay(100))

            return
                match language.ToUpper() with
                | "POLISH" -> "Cześć"
                | "ENGLISH" -> "Hello"
                | _ -> failwith "Not supported language"
        }

module Clients =
    let getClientName id =
        async {
            do! Async.AwaitTask(Task.Delay(150))

            return
                match id with
                | 1 -> "Admin"
                | 2 -> "Marcin"
                | id -> failwith $"User of id {id} doesn't exist"
        }

Z poziomu projektu C# na sygnatura handlera to trochę dziwny twór. Potrafi zaskoczyć i przestraszyć.

public static FSharpAsync<string> getGreeting(FSharpFunc<string,FSharpAsync<string>> getGreeting, FSharpFunc<int,FSharpAsync<string>> getName, string language, int clientId)
in class SampleHandler

Proste wywołanie znane z poprzedniego przykładu nie wchodzi w grę. Funkcja wymaga przekazania zależności, które trzeba skonwertować na typy FSharpowe. Jak to zrobić? Być może sposobów na połączenie tych światów jest więcej, ale ja znalazłem trzy.

1. Opakowanie w klasę

Pierwszym pomysłem, który przyszedł mi do głowy było opakowanie handlera w klasę CSharpową. W ten sposób, za fasadą klasy, schowałem wszelkie niekompatybilności i typy mogące spowodować dyskomfort.

public class SampleHandler
{
    private readonly FSharpFunc<string, FSharpAsync<string>> _getGreeting;
    private readonly FSharpFunc<int, FSharpAsync<string>> _getClientName;

    public SampleHandler(FSharpFunc<string, FSharpAsync<string>> getGreeting, FSharpFunc<int, FSharpAsync<string>> getClientName)
    {
        _getGreeting = getGreeting;
        _getClientName = getClientName;
    }

    public async Task<string> GetGreeting(string language, int id)
    {
        var result = FSharpHandlers.SampleHandler.getGreeting(_getGreeting, _getClientName, language, id);
        return await FSharpAsync.StartAsTask(result, FSharpOption<TaskCreationOptions>.None, FSharpOption<CancellationToken>.None);
    }
}

W tak prostym przypadku zależności mogłyby zostać utworzone w konstruktorze lub przekazane bezpośrednio do funkcji. Jednakże, ze względu na testowalność i utrzymywalność zdecydowałem się na wstrzyknięcie ich przez konstruktor. Tak postąpilibyśmy na produkcji. Zależności i handler zarejestrowałem przy starcie aplikacji

builder.Services.AddTransient((_) =>
{
    var func = FSharpHandlers.Dependencies.Greetings.getGreeting;
    return FuncConvert.FromFunc(func);
});

builder.Services.AddTransient(_ =>
{
    var func = FSharpHandlers.Dependencies.Clients.getClientName;
    return FuncConvert.FromFunc(func);
});

builder.Services.AddTransient<SampleHandler>();

Dzięki temu mogłem tę klasę wstrzyknąć i zawołać jej metody przy strzale do endpointa

app.MapGet("/Class/", (SampleHandler handler) => handler.GetGreeting("English", 1));

Rozwiązanie działa i nawet sprytnie ukrywa niedopasowanie dwóch światów. Jedna nie byłem z niego w pełni zadowolony. Każdy kolejny handler wymagał utworzenia kolejnej klasy co znaczenie zwiększało ilość kodu, który nic nie robi a trzeba go utrzymywać. Dodatkowo wszystkie zależności trzeba zarejestrować jako funkcje. Przy większej skali może się z tego zrobić niezły bałagan.

2. Wykorzystanie delegata

Aby zmniejszyć trochę ilość niepotrzebnego kodu pozbyłem się klasy i wykorzystałem delegat. Przypisałem do niego funkcję FSharpową i zarejestrowałem w kontenerze zależności.

builder.Services.AddTransient(serviceProvider =>
{
    var getGreeting = serviceProvider.GetRequiredService<FSharpFunc<string, FSharpAsync<string>>>();
    var getClient = serviceProvider.GetRequiredService<FSharpFunc<int, FSharpAsync<string>>>();

    Task<string> Handler(string lang, int id) => FSharpAsync.StartAsTask(FSharpHandlers.SampleHandler.getGreeting(getGreeting, getClient, lang, id),
        FSharpOption<TaskCreationOptions>.None, FSharpOption<CancellationToken>.None);

    return Handler;
});

W ten sposób mogę uzyskać dokładnie ten sam wynik co wyżej ale bez powoływania się na konkretną klasę. Wykorzystuję wstrzykiwanie po sygnaturze funkcji

app.MapGet("/Func/", (Func<string,int, Task<string>> handler) => handler("Polish", 2));

Ilość kodu trochę się zmniejszyła. Jeden plik mniej. Niestety bardzo szybko okazało się, że to podejście nie do końca działa. Gdy zaczniemy rejestrować wszystko w powyższy sposób, szybko okaże się, że wystąpi kolizja sygnatur. Mam na myśli, że zupełnie inna funkcja również ma taką samą sygnaturę

Func<string,int, Task<string>>

ale robi zupełnie co innego.

Można by pewnie zarejestrować delegat i nadać mu nazwę, jednak wbudowany kontener dotnetowy tego nie wspiera. Trzeba więc albo wrócić do opakowania funkcji w klasy, albo opakować parametry wejściowe jako silnie typowane DTOsy. Ewentualnie wprowadzić nową bibliotekę (np. Autofac [2]), która wspiera tego typu rejestracje. Koniec końców powstanie dużo niepotrzebnego kodu. To samo tyczy się rejestracji zależności funkcji.

W produkcyjnym kodzie takie wygibasy są raczej niepożądane. Raz, że czyta się to średnio. Dwa, że trzeba to zrozumieć i uważać aby nie popełnić jakiegoś błędu przy rejestracji.

3. Dodanie middleware

Ale jest jeszcze jedno wyjście! Z pomocą przychodzi żyrafa. Choć sam framework znałem i wykorzystywałem do zbudowania całej aplikacji w F# od początku do końca to na początku nie potrafiłem połączyć kropek. Brakuje jakoś wzmianki w dokumentacji projektu, że da się go wykorzystać do integracji projektów C# i F#. Zostawmy na chwilę ten off-top.

W wielkim skrócie jest to mikro framework Giraffe[3], który pozwala wpiąć się w pipeline aplikacji przez middleware. Został on stworzony z myślą o wykorzystaniu funkcyjnego podejścia od początku do końca w całej aplikacji. Dzięki temu można zrezygnować ze standardowych controllerów. Parę linijek pozwala na dobre połączyć te dwa, nie do końca kompatybilne światy w naprawdę przystępny sposób.

Projekt w F#

Proces wygląda tak, że tworzymy zwykły projekt FSharpowy, jakby osobny host, który będzie potrzebował doimportowania jednej paczki

<ItemGroup>
  <PackageReference Include="Giraffe" Version="5.0.0" />
</ItemGroup>

Dalej wszystko wygląda jak byśmy tworzyli klasyczne API w F#. Będzie ono jednak uproszczone bo sprowadzi się tylko do zmapowania endpointów na konkretne funkcje. Cała reszta zostaje po stronie C#.

Zacznijmy od stworzenia Composition Roota

type CompositionRoot =
    { ReadGreetings: string -> int -> Async<string> }

module CompositionRoot =
    let compose () : CompositionRoot =
        { ReadGreetings =
              FSharpHandlers.SampleHandler.getGreeting
                  FSharpHandlers.Dependencies.Greetings.getGreeting
                  FSharpHandlers.Dependencies.Clients.getClientName }

Następnie trzeba zmapować endpoint na funkcję

let greetingsHandler compositionRoot : HttpHandler =
    fun (next : HttpFunc) (ctx : HttpContext) ->
        task {
            let! x = compositionRoot.ReadGreetings "Polish" 1
            return! Successful.OK x next ctx
        }


let endpoints compositionRoot =
    [
        GET [
            route  "/Giraffe" (greetingsHandler compositionRoot)
        ]
    ]

i jeżeli chodzi o F# to tyle.

C# WebApi

Pozostało już tylko wykorzystać zmapowane endpointy w hoście. Po dodaniu nuget Giraffe wepnijmy go w pipeline.

Zacznijmy od zarejestrowania serwisu żyrafy

Middleware.ServiceCollectionExtensions.AddGiraffe(builder.Services);

złóżmy wcześniej stworzone drzewo zależności

var compositionRoot = CompositionRootModule.compose();

i zmapujmy endpointy

app.UseGiraffe(HttpHandlers.endpoints(compositionRoot)); 

I tyle. Od tego moment za pomocą jednego hosta i dodatkowego middleware oddzieliliśmy dwa światy, które nie współgrają ze sobą tak dobrze jak jest to przedstawiane.

Podsumowanie

Kiedy wybrać które rozwiązanie? Na to pytanie nie ma prostej odpowiedzi. Trzeba rozważyć wszystkie ograniczenia i wymagania. Jednakże, gdy nie ma ku temu przeciwwskazań, ja wybiorę ostatni sposób. Moim zdaniem jest najbardziej intuicyjny.

Kod źródłowy: GitHub repo

Bibliografia

[1] HOF https://en.wikipedia.org/wiki/Higher-order_function
[2] Autofac, [biblioteka] https://autofac.org/
[3] Giraffe, [biblioteka] https://giraffe.wiki/ \

Zostaw komentarz