Anwendungsarchitektur / -zusammensetzung in F #

76

Ich habe in letzter Zeit SOLID in C # auf ein ziemlich extremes Niveau gebracht und irgendwann festgestellt, dass ich heutzutage im Wesentlichen nicht viel anderes mache, als Funktionen zu komponieren. Und nachdem ich vor kurzem wieder angefangen hatte, mir F # anzuschauen, dachte ich mir, dass dies wahrscheinlich die viel passendere Wahl der Sprache für vieles ist, was ich jetzt mache, also möchte ich versuchen, ein reales C # -Projekt auf F # zu portieren als Proof of Concept. Ich denke, ich könnte den eigentlichen Code abrufen (auf eine sehr un-idiomatische Weise), aber ich kann mir nicht vorstellen, wie eine Architektur aussehen würde, die es mir ermöglicht, auf eine ähnlich flexible Art und Weise wie in C # zu arbeiten.

Damit meine ich, dass ich viele kleine Klassen und Schnittstellen habe, die ich mit einem IoC-Container zusammenstelle, und ich verwende auch häufig Muster wie Decorator und Composite. Dies führt zu einer (meiner Meinung nach) sehr flexiblen und weiterentwickelbaren Gesamtarchitektur, die es mir ermöglicht, die Funktionalität an jedem Punkt der Anwendung einfach zu ersetzen oder zu erweitern. Je nachdem, wie groß die erforderliche Änderung ist, muss ich möglicherweise nur eine neue Implementierung einer Schnittstelle schreiben, diese in der IoC-Registrierung ersetzen und fertig sein. Selbst wenn die Änderung größer ist, kann ich Teile des Objektdiagramms ersetzen, während der Rest der Anwendung einfach so bleibt wie zuvor.

Jetzt mit F # habe ich keine Klassen und Schnittstellen (ich weiß, dass ich das kann, aber ich denke, das ist nicht der Punkt, an dem ich die eigentliche funktionale Programmierung durchführen möchte), ich habe keine Konstruktorinjektion und ich habe keine IoC Behälter. Ich weiß, dass ich mit Funktionen höherer Ordnung so etwas wie ein Decorator-Muster erstellen kann, aber das scheint mir nicht die gleiche Flexibilität und Wartbarkeit zu geben wie Klassen mit Konstruktorinjektion.

Betrachten Sie diese C # -Typen:

public class Dings
{
    public string Lol { get; set; }

    public string Rofl { get; set; }
}

public interface IGetStuff
{
    IEnumerable<Dings> For(Guid id);
}

public class AsdFilteringGetStuff : IGetStuff
{
    private readonly IGetStuff _innerGetStuff;

    public AsdFilteringGetStuff(IGetStuff innerGetStuff)
    {
        this._innerGetStuff = innerGetStuff;
    }

    public IEnumerable<Dings> For(Guid id)
    {
        return this._innerGetStuff.For(id).Where(d => d.Lol == "asd");
    }
}

public class GeneratingGetStuff : IGetStuff
{
    public IEnumerable<Dings> For(Guid id)
    {
        IEnumerable<Dings> dingse;

        // somehow knows how to create correct dingse for the ID

        return dingse;
    }
}

Ich werde meinen IoC-Container anweisen, AsdFilteringGetStufffür IGetStuffund GeneratingGetStufffür seine eigene Abhängigkeit von dieser Schnittstelle aufzulösen . Wenn ich nun einen anderen Filter benötige oder den Filter insgesamt entferne, muss ich möglicherweise die entsprechende Implementierung von IGetStuffund dann einfach die IoC-Registrierung ändern. Solange die Benutzeroberfläche gleich bleibt, muss ich keine Inhalte in der Anwendung berühren . OCP und LSP, aktiviert durch DIP.

Was mache ich jetzt in F #?

type Dings (lol, rofl) =
    member x.Lol = lol
    member x.Rofl = rofl

let GenerateDingse id =
    // create list

let AsdFilteredDingse id =
    GenerateDingse id |> List.filter (fun x -> x.Lol = "asd")

Ich mag, wie viel weniger Code das ist, aber ich verliere die Flexibilität. Ja, ich kann anrufen AsdFilteredDingseoder GenerateDingseam selben Ort, da die Typen gleich sind - aber wie entscheide ich, welchen ich anrufen soll, ohne ihn an der Anrufstelle fest zu codieren? Auch wenn diese beiden Funktionen austauschbar sind, kann ich die Generatorfunktion im Inneren nicht ersetzen, AsdFilteredDingseohne diese Funktion ebenfalls zu ändern. Das ist nicht sehr schön.

Nächster Versuch:

let GenerateDingse id =
    // create list

let AsdFilteredDingse (generator : System.Guid -> Dings list) id =
    generator id |> List.filter (fun x -> x.Lol = "asd")

Jetzt kann ich AsdFilteredDingse zu einer Funktion höherer Ordnung machen, aber die beiden Funktionen sind nicht mehr austauschbar. Beim zweiten Gedanken sollten sie es wahrscheinlich sowieso nicht sein.

Was könnte ich noch tun? Ich könnte das "Composition Root" -Konzept aus meinem C # SOLID in der letzten Datei des F # -Projekts nachahmen. Die meisten Dateien sind nur Sammlungen von Funktionen, dann habe ich eine Art "Registrierung", die den IoC-Container ersetzt, und schließlich gibt es eine Funktion, die ich aufrufe, um die Anwendung tatsächlich auszuführen, und die Funktionen aus der "Registrierung" verwendet. In der "Registrierung" weiß ich, dass ich eine Funktion vom Typ (Guid -> Dings-Liste) benötige, die ich aufrufen werde GetDingseForId. Dies ist die, die ich nenne, niemals die einzelnen Funktionen, die zuvor definiert wurden.

Für den Dekorateur wäre die Definition

let GetDingseForId id = AsdFilteredDingse GenerateDingse

Um den Filter zu entfernen, würde ich das in ändern

let GetDingseForId id = GenerateDingse

Der Nachteil (?) Davon ist, dass alle Funktionen, die andere Funktionen verwenden, sinnvollerweise Funktionen höherer Ordnung sein müssten und meine "Registrierung" alle Funktionen zuordnen müsste , die ich verwende, da die zuvor definierten tatsächlichen Funktionen keine aufrufen können später definierte Funktionen, insbesondere nicht die aus der "Registrierung". Ich könnte auch auf zirkuläre Abhängigkeitsprobleme mit den "Registrierungs" -Zuordnungen stoßen.

Ist irgendetwas davon sinnvoll? Wie erstellen Sie wirklich eine F # -Anwendung, die wartbar und entwicklungsfähig ist (ganz zu schweigen von testbar)?

TeaDrivenDev
quelle

Antworten:

59

Dies ist einfach, wenn Sie feststellen, dass die objektorientierte Konstruktorinjektion sehr genau der Anwendung funktionaler Teilfunktionen entspricht .

Zuerst würde ich Dingsals Datensatztyp schreiben :

type Dings = { Lol : string; Rofl : string }

In F # kann die IGetStuffSchnittstelle mit der Signatur auf eine einzige Funktion reduziert werden

Guid -> seq<Dings>

Ein Client , der diese Funktion verwendet, würde sie als Parameter verwenden:

let Client getStuff =
    getStuff(Guid("055E7FF1-2919-4246-876E-1DA71980BE9C")) |> Seq.toList

Die Signatur für die ClientFunktion lautet:

(Guid -> #seq<'b>) -> 'b list

Wie Sie sehen können, wird eine Funktion der Zielsignatur als Eingabe verwendet und eine Liste zurückgegeben.

Generator

Die Generatorfunktion ist einfach zu schreiben:

let GenerateDingse id =
    seq {
        yield { Lol = "Ha!"; Rofl = "Ha ha ha!" }
        yield { Lol = "Ho!"; Rofl = "Ho ho ho!" }
        yield { Lol = "asd"; Rofl = "ASD" } }

Die GenerateDingseFunktion hat diese Signatur:

'a -> seq<Dings>

Dies ist eigentlich mehr Generika als Guid -> seq<Dings>, aber das ist kein Problem. Wenn Sie nur die komponieren wollen Clientmit GenerateDingse, können Sie einfach verwenden Sie es wie folgt aus :

let result = Client GenerateDingse

Welches würde alle drei DingWerte von zurückgeben GenerateDingse.

Dekorateur

Der ursprüngliche Dekorateur ist etwas schwieriger, aber nicht viel. Anstatt den dekorierten (inneren) Typ als Konstruktorargument hinzuzufügen, fügen Sie ihn im Allgemeinen nur als Parameterwert zu einer Funktion hinzu:

let AdsFilteredDingse id s = s |> Seq.filter (fun d -> d.Lol = "asd")

Diese Funktion hat diese Signatur:

'a -> seq<Dings> -> seq<Dings>

Das ist nicht ganz das, was wir wollen, aber es ist einfach, es zu komponieren GenerateDingse:

let composed id = GenerateDingse id |> AdsFilteredDingse id

Die composedFunktion hat die Signatur

'a -> seq<Dings>

Genau das, wonach wir suchen!

Sie können nun Clientmit composedwie folgt aus :

let result = Client composed

das wird nur zurückkehren [{Lol = "asd"; Rofl = "ASD";}].

Sie müssen nicht haben , um die zu definieren composederste Funktion; Sie können es auch vor Ort komponieren:

let result = Client (fun id -> GenerateDingse id |> AdsFilteredDingse id)

Dies kehrt auch zurück [{Lol = "asd"; Rofl = "ASD";}].

Alternativer Dekorateur

Das vorherige Beispiel funktioniert gut, dekoriert aber eine ähnliche Funktion nicht wirklich . Hier ist eine Alternative:

let AdsFilteredDingse id f = f id |> Seq.filter (fun d -> d.Lol = "asd")

Diese Funktion hat die Signatur:

'a -> ('a -> #seq<Dings>) -> seq<Dings>

Wie Sie sehen können, ist das fArgument eine weitere Funktion mit derselben Signatur, sodass es dem Decorator-Muster ähnlicher ist. Sie können es so komponieren:

let composed id = GenerateDingse |> AdsFilteredDingse id

Auch hier können Sie verwenden , Clientmit composedwie folgt aus :

let result = Client composed

oder inline wie folgt:

let result = Client (fun id -> GenerateDingse |> AdsFilteredDingse id)

Weitere Beispiele und Prinzipien zum Erstellen ganzer Anwendungen mit F # finden Sie in meinem Online-Kurs zur funktionalen Architektur mit F # .

Weitere Informationen zu objektorientierten Prinzipien und deren Zuordnung zur funktionalen Programmierung finden Sie in meinem Blogbeitrag zu den SOLID-Prinzipien und deren Anwendung auf FP .

Mark Seemann
quelle
5
Danke Mark für die ausführliche Antwort. Was mir jedoch noch unklar ist, ist, wo ich in einer Anwendung mit mehr als einer trivialen Anzahl von Funktionen komponieren soll. Ich habe in der Zwischenzeit eine kleine C # -Anwendung auf F # portiert und hier zur Überprüfung veröffentlicht. Ich habe dort die erwähnte "Registrierung" als CompositionModul implementiert , im Wesentlichen als "Poor Man's DI" -Kompositionswurzel. Ist das ein gangbarer Weg?
TeaDrivenDev
@TeaDrivenDev Wie Sie vielleicht bemerkt haben, habe ich den Beitrag aktualisiert, indem ich einen Link zu einem Blog-Beitrag hinzugefügt habe, der etwas tiefer geht. Wenn es um Komposition geht, ist eines der interessanten Dinge an F #, dass der Compiler Zirkelverweise verhindert, sodass Komposition und Entkopplung viel sicherer sind als in C # . Der Code, mit dem Sie verknüpft haben, wird gegen Ende erstellt, sodass er gut aussieht.
Mark Seemann
Eine alternative Definition der Funktionen könnte sein: let AdsFilteredDingse = Seq.filter (fun d -> d.Lol = "asd")und let composed = GenerateDingse >> AdsFilteredDingse. Auf diese Weise können Sie den Funktionskompositionsoperator verwenden und die ID in AdsFilteredDingse weglassen. Aber auch hier ist es, wie Mark erwähnte, kein wirklicher Dekorateur im ursprünglichen OO-Sinne.
Simon Stender Boisen
Ich habe das versucht, aber es gibt mir einen Compilerfehler, obwohl die Signatur von composedgut aussieht: "Fehler FS0030: Wertebeschränkung. Der Wert 'zusammengesetzt' wurde abgeleitet, um den generischen Typ val zusammenzusetzen: ('_a -> seq < Dings>) Machen Sie die Argumente entweder zu 'zusammengesetzt' explizit oder fügen Sie eine Typanmerkung hinzu, wenn Sie nicht beabsichtigen, dass sie generisch ist. "
Mark Seemann