.Net Core Api: Abrufen von Daten in einer Anfrage aus verschiedenen Quellen

.Net Core verfügt über einen integrierten Modellbindungsmechanismus, mit dem nicht nur Eingabeparameter in Controllern akzeptiert, sondern auch Objekte mit ausgefüllten Feldern sofort empfangen werden können. Auf diese Weise können Sie mithilfe der Modellvalidierung alle erforderlichen Prüfungen in ein solches Objekt einbetten.

Hier sind nur die Daten aufgeführt, die für die Funktion der API erforderlich sind. Sie stammen nicht nur von Query oder Body. Einige Daten müssen von Headern empfangen werden (in meinem Fall gab es json in base64), einige Daten sollten von externen Diensten oder ActionRoute stammen, wenn Sie REST verwenden. Sie können Ihre Bindung verwenden, um Daten von dort abzurufen. Es stimmt, es gibt ein Problem: Wenn Sie sich entscheiden, die Kapselung nicht zu unterbrechen und das Modell über den Konstruktor zu initialisieren, müssen Sie Schamanen.

Für mich und zukünftige Generationen habe ich beschlossen, so etwas wie Anweisungen für die Verwendung von Bindung und Schamanismus damit zu schreiben.

Problem


Ein typischer Controller sieht ungefähr so ​​aus:

[HttpGet]
public async Task<IActionResult> GetSomeData([FromQuery[IncomeData someData)
{
    var moreData = GetFromHeaderAndDecode("X-Property");
    if (moreData.Id == 0)
    {
        return StatusCode(400, "Nginx doesnt know your id");
    }
    var externalData = GetFromExternalService("http://myservice.com/MoreData");
    if (externalData == null)
    {
        return StatusCode(500, "Cant connect to external service");
    }
    var finalData = new FinalData(someData, moreData, externalData);
    return _myService.Handle(finalData);
}

Als Ergebnis erhalten wir die folgenden Probleme:

  1. Die Validierungslogik wird durch das Anforderungsobjekt, die Anforderungsmethode aus dem Header, die Anforderungsmethode aus dem Dienst und die Controller-Methode verschmiert. Um sicherzustellen, dass die erforderliche Überprüfung definitiv vorhanden ist, müssen Sie eine vollständige Untersuchung durchführen!
  2. Die benachbarte Controller-Methode hat genau den gleichen Code. Kopieren-Einfügen-Programmierung im Angriff.
  3. Normalerweise gibt es viel mehr Überprüfungen als im Beispiel, und als Ergebnis ist die einzige wichtige Zeile - der Aufruf der Verarbeitungsmethode für Geschäftslogik - in einem Codehaufen verborgen. Um ihn zu sehen und zu verstehen, was hier im Allgemeinen passiert, sind einige Anstrengungen erforderlich.

Benutzerdefinierte Bindung (einfacher Modus)


Ein Teil des Problems kann gelöst werden, indem Sie Ihren Handler in der Anforderungsverarbeitungspipeline implementieren. Korrigieren Sie dazu zunächst unseren Controller, indem Sie das endgültige Objekt sofort an die Methode übergeben. Es sieht viel besser aus, oder?

[HttpGet]
public async Task<IActionResult> GetSomeData([FromQuery]FinalData finalData)
{
    return _myService.Handle(finalData);
}

Erstellen Sie als Nächstes Ihren Ordner für den Typ MoreData.

public class MoreDataBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
        if (moreData != null)
        {
            bindingContext.Result = ModelBindingResult.Success(moreData);
        }
        return Task.CompletedTask;
    }
    private MoreData GetFromHeaderAndDecode(IHeaderDictionary headers) { ... }
}

Schließlich korrigieren wir das FinalData-Modell, indem wir der dortigen Eigenschaft die Binderbindung hinzufügen:

public class FinalData
{
    public int SomeDataNumber { get; set; }

    public string SomeDataText { get; set; }

    [ModelBinder(BinderType = typeof(MoreDataBinder))]
    public MoreData MoreData { get; set; }
}

Es ist bereits besser, aber die Hämorrhoiden haben zugenommen: Jetzt müssen Sie wissen, dass wir einen speziellen Handler haben, und dies in allen Modellen angeben. Aber es ist lösbar.

Erstellen Sie Ihren BinderProvider:

public class MoreDataBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        var modelType = context.Metadata.UnderlyingOrModelType;
        if (modelType == typeof(MoreData))
        {
            return new BinderTypeModelBinder(typeof(MoreDataBinder));
        }
        return null;
    }
}

Und registrieren Sie es im Startup:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddMvc(options =>
    {
        options.ModelBinderProviders.Insert(0, new MoreDataBinderProvider());
     });
}

Der Anbieter wird für jedes Modellobjekt in der Reihenfolge der Warteschlange aufgerufen. Wenn unser Anbieter den gewünschten Typ erfüllt, gibt er den gewünschten Ordner zurück. Wenn nicht, funktioniert der Standardordner. Wenn wir also den Typ von MoreData angeben, wird er aus dem Header übernommen und dekodiert, und spezielle Attribute in den Modellen müssen nicht angegeben werden.

Benutzerdefinierte Bindung (Hard Mode)


All dies ist großartig, aber es gibt eine Sache, aber: Damit die Magie funktioniert, muss unser Modell öffentliche Eigenschaften mit Set haben. Aber was ist mit der Kapselung? Was ist, wenn ich die Anforderungsdaten an verschiedene Stellen in der Cloud übertragen möchte und weiß, dass sie dort nicht geändert werden?

Das Problem ist, dass der Standardordner nicht für Modelle funktioniert, die keinen Standardkonstruktor haben. Aber was hindert uns daran, unsere eigenen zu schreiben?
Der Dienst, für den ich diesen Code geschrieben habe, verwendet kein REST, die Parameter werden nur über Query und Body übertragen und es werden nur zwei Arten von Anforderungen verwendet - Get
und Post. Dementsprechend unterscheidet sich die Verarbeitungslogik im Fall der REST-API geringfügig.
Im Allgemeinen bleibt der Code unverändert. Nur unser Ordner muss verfeinert werden, damit er ein Objekt erstellt und seine privaten Felder ausfüllt. Weiterhin werde ich Codestücke mit Kommentaren geben, die nicht interessiert sind - am Ende des Artikels unter der Katze befindet sich die gesamte Auflistung der Klasse.

Lassen Sie uns zunächst feststellen, ob MoreData die einzige Eigenschaft der Klasse ist. Wenn ja, müssen Sie das Objekt selbst erstellen (Hallo, Aktivator). Wenn nicht, erledigt JsonConvert die Aufgabe perfekt, und wir schieben einfach die erforderlichen Daten in die Eigenschaft.

private static bool NeedActivator(IReflect modelType)
{
    var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
    var properties = modelType.GetProperties(propFlags);

    return properties.Select(p => p.Name).Distinct().Count() == 1;
}

Das Erstellen eines Objekts über JsonConvert ist für Abfragen mit Body einfach:

private static object? GetModelFromBody(ModelBindingContext bindingContext, Type modelType)
{
    using var reader = new StreamReader(bindingContext.HttpContext.Request.Body);
    var jsonString = reader.ReadToEnd();
    var data = JsonConvert.DeserializeObject(jsonString, modelType);
    return data;
}

Aber mit Query musste ich mich hinlegen. Ich würde mich freuen, wenn jemand eine schönere Lösung vorschlagen kann.

Beim Übergeben eines Arrays werden mehrere Parameter mit demselben Namen erhalten. Das Umwandeln in einen flachen Typ hilft, aber durch die Serialisierung werden zusätzliche Anführungszeichen für das Array [] gesetzt, die manuell entfernt werden müssen.

private static object? GetModelFromQuery(ModelBindingContext bindingContext, Type modelType)
{
    var valuesDictionary = QueryHelpers.ParseQuery(bindingContext.HttpContext.Request.QueryString.Value);
    var jsonDictionary = valuesDictionary.ToDictionary(pair => pair.Key, pair => pair.Value.Count < 2 ? pair.Value.ToString() : $"[{pair.Value}]");

    var jsonStr = JsonConvert.SerializeObject(jsonDictionary).Replace("\"[", "[").Replace("]\"", "]");
    var data = JsonConvert.DeserializeObject(jsonStr, modelType);
    return data;
}

Schließlich ist es beim Erstellen des Objekts erforderlich, unsere Daten in sein Privateigentum zu schreiben. Über diesen Schamanismus habe ich am Anfang des Artikels gesprochen. Ich habe diese Lösung hier gefunden , wofür ich mich beim Autor bedanke.

private void ForceSetValue(PropertyInfo propertyInfo, object obj, object value)
{
    var propName = $"<{propertyInfo.Name}>k__BackingField";
    var propFlags = BindingFlags.Instance | BindingFlags.NonPublic;
            
    obj.GetType().GetField(propName, propFlags)?.SetValue(obj, value);
}

Nun, wir kombinieren diese Methoden in einem einzigen Aufruf:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
    if (moreData == null)
    {
        return Task.CompletedTask;
    }

    var modelType = bindingContext.ModelMetadata.UnderlyingOrModelType;
    if (NeedActivator(modelType))
    {
        var data = Activator.CreateInstance(modelType, moreData);
        bindingContext.Result = ModelBindingResult.Success(data);

        return Task.CompletedTask;
    }

    var model = bindingContext.HttpContext.Request.Method == "GET"
                            ? GetModelFromQuery(bindingContext, modelType)
                            : GetModelFromBody(bindingContext, modelType);

    if (model is null)
    {
        throw new Exception("  ");
    }

    var ignoreCase = StringComparison.InvariantCultureIgnoreCase;
    var dataProperty = modelType.GetProperties()
                            .FirstOrDefault(p => p.Name.Equals(typeof(T).Name, ignoreCase));

    if (dataProperty != null)
    {
        ForceSetValue(dataProperty, model, moreData);
    }

    bindingContext.Result = ModelBindingResult.Success(model);
    return Task.CompletedTask;
}

Es bleibt BinderProvider so zu reparieren, dass es auf alle Klassen mit der gewünschten Eigenschaft reagiert:

public class MoreDataBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
       var modelType = context.Metadata.UnderlyingOrModelType;
       if (HasDataProperty(modelType))
       {
           return new BinderTypeModelBinder(typeof(PrivateDataBinder<MoreData>));
       }
       return null;
    }
    private bool HasDataProperty(IReflect modelType)
    {
        var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
        var properties = modelType.GetProperties(propFlags);

        return properties.Select(p => p.Name) .Contains(nameof(MoreData));
    }
}


Das ist alles. Binder erwies sich als etwas komplizierter als im einfachen Modus, aber jetzt können wir ohne zusätzlichen Aufwand „externe“ Eigenschaften in allen Methoden aller Controller binden. Von den Minuspunkten:

  1. Der Konstruktor von Objekten mit privaten Feldern muss das Attribut [JsonConstrustor] angeben. Dies passt jedoch vollständig in die Logik des Modells und beeinträchtigt dessen Wahrnehmung nicht.
  2. Irgendwo müssen Sie möglicherweise MoreData nicht aus dem Header abrufen. Dies wird jedoch durch Erstellen einer separaten Klasse behandelt.
  3. Der Rest des Teams muss sich der Präsenz von Magie bewusst sein. Aber die Dokumentation wird die Menschheit retten.

Eine vollständige Liste des resultierenden Binders finden Sie hier:

PrivateMoreDataBinder.cs
public class PrivateDataBinder<T> : IModelBinder
    {
        /// <summary></summary>
        /// <param name="bindingContext"></param>
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
            if (moreData == null)
            {
                return Task.CompletedTask;
            }

            var modelType = bindingContext.ModelMetadata.UnderlyingOrModelType;
            if (NeedActivator(modelType))
            {
                var data = Activator.CreateInstance(modelType, moreData);
                bindingContext.Result = ModelBindingResult.Success(data);

                return Task.CompletedTask;
            }

            var model = bindingContext.HttpContext.Request.Method == "GET"
                            ? GetModelFromQuery(bindingContext, modelType)
                            : GetModelFromBody(bindingContext, modelType);

            if (model is null)
            {
                throw new Exception("  ");
            }

            var ignoreCase = StringComparison.InvariantCultureIgnoreCase;
            var dataProperty = modelType.GetProperties()
                                        .FirstOrDefault(p => p.Name.Equals(typeof(T).Name, ignoreCase));

            if (dataProperty != null)
            {
                ForceSetValue(dataProperty, model, moreData);
            }

            bindingContext.Result = ModelBindingResult.Success(model);

            return Task.CompletedTask;
        }

        private static object? GetModelFromQuery(ModelBindingContext bindingContext,
                                                 Type modelType)
        {
            var valuesDictionary = QueryHelpers.ParseQuery(bindingContext.HttpContext.Request.QueryString.Value);

            var jsonDictionary = valuesDictionary.ToDictionary(pair => pair.Key, pair => pair.Value.Count < 2 ? pair.Value.ToString() : $"[{pair.Value}]");

            var jsonStr = JsonConvert.SerializeObject(jsonDictionary)
                                     .Replace("\"[", "[")
                                     .Replace("]\"", "]");

            var data = JsonConvert.DeserializeObject(jsonStr, modelType);

            return data;
        }

        private static object? GetModelFromBody(ModelBindingContext bindingContext,
                                                Type modelType)
        {
            using var reader = new StreamReader(bindingContext.HttpContext.Request.Body);
            var jsonString = reader.ReadToEnd();
            var data = JsonConvert.DeserializeObject(jsonString, modelType);

            return data;
        }

        private static bool NeedActivator(IReflect modelType)
        {
            var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
            var properties = modelType.GetProperties(propFlags);

            return properties.Select(p => p.Name).Distinct().Count() == 1;
        }

        private void ForceSetValue(PropertyInfo propertyInfo, object obj, object value)
        {
            var propName = $"<{propertyInfo.Name}>k__BackingField";
            var propFlags = BindingFlags.Instance | BindingFlags.NonPublic;
            
            obj.GetType().GetField(propName, propFlags)?.SetValue(obj, value);
        }

        private T GetFromHeaderAndDecode(IHeaderDictionary headers) { return (T)Activator.CreateInstance(typeof(T), new object[] { "ok" }); }
    }


All Articles