.Net Core Api: obtenir des données dans une demande de différentes sources

.Net Core possède un mécanisme de liaison de modèle intégré, qui permet non seulement d'accepter les paramètres d'entrée dans les contrôleurs, mais de recevoir immédiatement des objets avec des champs remplis. Cela vous permet d'incorporer toutes les vérifications nécessaires dans un tel objet à l'aide de la validation de modèle.

Voici juste les données nécessaires au fonctionnement de l'API, qui nous viennent non seulement de Query ou Body. Certaines données doivent être reçues des en-têtes (dans mon cas, il y avait json dans base64), certaines données devraient provenir de services externes ou d'ActionRoute si vous utilisez REST. Vous pouvez utiliser votre liaison pour obtenir des données à partir de là. Certes, il y a un problème: si vous décidez de ne pas casser l'encapsulation et d'initialiser le modèle via le constructeur, vous devrez chaman.

Pour moi et pour les générations futures, j'ai décidé d'écrire quelque chose comme des instructions pour utiliser Binding et le chamanisme avec.

Problème


Un contrôleur typique ressemble à ceci:

[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);
}

En conséquence, nous obtenons les problèmes suivants:

  1. La logique de validation est étalée par l'objet de demande, la méthode de demande de l'en-tête, la méthode de demande du service et la méthode du contrôleur. Pour vous assurer que le contrôle nécessaire est bien là, vous devez mener toute une enquête!
  2. La méthode de contrôleur adjacente aura exactement le même code. Programmation copier-coller en attaque.
  3. Habituellement, il y a beaucoup plus de contrôles que dans l'exemple, et par conséquent, la seule ligne significative - l'appel à la méthode de traitement de la logique métier - est cachée dans un tas de code. Le voir et comprendre ce qui se passe ici en général demande un certain effort.

Reliure personnalisée (mode simplifié)


Une partie du problème peut être résolue en implémentant votre gestionnaire dans le pipeline de traitement des demandes. Pour ce faire, corrigez d'abord notre contrôleur en passant immédiatement l'objet final à la méthode. Ça a l'air beaucoup mieux, non?

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

Ensuite, créez votre classeur pour le type 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) { ... }
}

Enfin, nous allons corriger le modèle FinalData en y ajoutant la liaison du classeur à la propriété:

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

    public string SomeDataText { get; set; }

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

C'est déjà mieux, mais les hémorroïdes ont augmenté: vous devez maintenant savoir que nous avons un gestionnaire spécial et l'indiquer dans tous les modèles. Mais c'est résoluble.

Créez votre 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;
    }
}

Et enregistrez-le dans Startup:

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

Le fournisseur est appelé pour chaque objet modèle par ordre de priorité. Si notre fournisseur répond au type souhaité, il retournera le classeur souhaité. Et sinon, le classeur par défaut fonctionnera. Alors maintenant, chaque fois que nous spécifions le type de MoreData, il sera pris et décodé de l'en-tête et les attributs spéciaux dans les modèles n'ont pas besoin d'être spécifiés.

Reliure personnalisée (mode difficile)


Tout cela est génial, mais il y a une chose: pour que la magie fonctionne, notre modèle doit avoir des propriétés publiques avec set. Mais qu'en est-il de l'encapsulation? Que se passe-t-il si je souhaite transférer les données de demande à divers endroits dans le cloud et savoir qu'elles n'y seront pas modifiées?

Le problème est que le classeur par défaut ne fonctionne pas pour les modèles qui n'ont pas de constructeur par défaut. Mais qu'est-ce qui nous empêche d'écrire le nôtre?
Le service pour lequel j'ai écrit ce code n'utilise pas REST, les paramètres sont transmis uniquement via Query et Body, et seuls deux types de requêtes sont utilisés - Get
et Post. Par conséquent, dans le cas de l'API REST, la logique de traitement sera légèrement différente.
En général, le code restera inchangé, seul notre classeur a besoin d'être affiné pour qu'il crée un objet et remplisse ses champs privés. De plus, je donnerai des morceaux de code avec des commentaires, qui ne sont pas intéressés - à la fin de l'article sous le chat est la liste complète de la classe.

Tout d'abord, déterminons si MoreData est la seule propriété de la classe. Si oui, alors vous devez créer l'objet vous-même (bonjour, Activator), et sinon, JsonConvert fera le travail parfaitement, et nous glissons simplement les données nécessaires dans la propriété.

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;
}

La création d'un objet via JsonConvert est simple, pour les requêtes avec Body:

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;
}

Mais avec Query j'ai dû me coucher. Je serais heureux si quelqu'un pouvait suggérer une solution plus belle.

Lors du passage d'un tableau, plusieurs paramètres du même nom sont obtenus. La conversion vers un type plat aide, mais la sérialisation met des guillemets supplémentaires sur le tableau [], qui doivent être supprimés manuellement.

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;
}

Enfin, lors de la création de l'objet, il est nécessaire d'écrire nos données dans sa propriété privée. C'est à propos de ce chamanisme que j'ai parlé au début de l'article. J'ai trouvé cette solution ici , merci beaucoup à l'auteur.

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);
}

Eh bien, nous combinons ces méthodes en un seul appel:

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;
}

Il reste à corriger BinderProvider pour qu'il réponde à toutes les classes avec la propriété souhaitée:

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));
    }
}


C'est tout. Binder s'est avéré être un peu plus compliqué qu'en mode facile, mais maintenant nous pouvons lier les propriétés «externes» dans toutes les méthodes de tous les contrôleurs sans efforts supplémentaires. Des inconvénients:

  1. Le constructeur d'objets avec des champs privés doit spécifier l'attribut [JsonConstrustor]. Mais cela s'inscrit complètement dans la logique du modèle et n'interfère pas avec sa perception.
  2. Quelque part, vous devrez peut-être obtenir MoreData pas de l'en-tête. Mais cela est traité en créant une classe distincte.
  3. Le reste de l'équipe doit être conscient de la présence de magie. Mais la documentation sauvera l'humanité.

Une liste complète du classeur résultant est ici:

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