ASP.NET Core – Custom Input formatters For Partial Update and Handle Under-posting

前言

之前的文章有谈过关于 ASP.NET Core 处理 under-posting 的方式.

它会使用 class default value. 许多时候这可能不是我们期望的. 比如当我们想要 patch update resource 的时候.

一种解决方法是把 DTO 改成 nullable 来表示 under-posting, 但这也不总是正确的, 毕竟也有可能它是想把 value set to null.

另一个方法是使用 JSON Patch, 但这个方法会让前端比较麻烦. 那为了实现需求只能做一些 Custom 的方案了

 

Model Binding

参考: Model Binding in ASP.NET Core

在说解决思路之前, 先了解一些基础知识. Model Binding 是 ASP.NET Core 把 Request 的信息投影到 Action parameter 的一个执行过程.

这就是为什么在 Action 阶段获取到的不是 JSON string 而是已经 deserialize 好的 instance. 就是这个 Model Binding 干的事儿.

 

Input Formatters

而在整个 Model Binding 中, Input Formatters 则是最终负责 deserialize JSON 的. 

 

解决思路

想解决 under-posting 的问题, 就必须在 ASP.NET Core 处理 JSON 之前, 一旦它 deserialize JSON 后, 我们就失去了 under-posting 的信息了.

所以效仿 JsonPatch 的做法就是 Custom Input Formatters.

我们可以让 deserialize 的 class implement 特定的 interface, 比如叫 IUnderPosting, 里面则拥有一个 UnderPostingPropertyNames.

拦截 format 以后, 先做一轮原生的 JSON deserialize, 然后在做一个 JsonNode parse, 然后把 under-posting 的 property 记入到对象中.

这个概念和 JSON overflow 类似 Handle overflow JSON.

于是我们就拥有了 under-posting 的信息. 往后, 无论是 validation, mapping 都可以利用这个信息去做事情, 比如 under-posting 的 property 就 ignore validation 同时也不需要 update entity.

 

预想的结果

按思路走大概是这样的

public interface IUnderPosting
{
    List<string> UnderPostingPropertyNames { get; set; }
}

public class UpdateProductDTO : IUnderPosting
{
    public string Name { get; set; } = "";
    public decimal Amount { get; set; }
    public List<string> UnderPostingPropertyNames { get; set; } = new();
}

在 Controller 可以依据 UnderPostingPropertyNames 去做逻辑处理.

[ApiController]
[Route("api/products")]
public class ProductController : ControllerBase
{
    [HttpPatch]
    public IActionResult UpdateProduct(UpdateProductDTO dto)
    {
        return Ok();
    }
}

 

探路

看源码

既然我们是想替代 ASP.NET Core build-in 的 JSON formatter, 那就必须先看看它长什么样. 要如何 customize.

SystemTextJsonInputFormatter.cs

TextInputFormatter 好理解, Custom formatters in ASP.NET Core Web API 里也用这个, IInputFormatterExceptionPolicy 就不太清楚了.

最基本需要实现的接口

public class MyInputFormatter2 : TextInputFormatter, IInputFormatterExceptionPolicy
{
    public InputFormatterExceptionPolicy ExceptionPolicy => throw new NotImplementedException();
    public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
    {
        throw new NotImplementedException();
    }
}

所以整个核心代码就在 ReadRequestBodyAsync 里

可以大致看出它的实现方式, 从 HttpContext 读取 body stream, 然后 deserialze, ModelType 就是要 binding 到的 class, 如果有 exception 比如 type mismatch, 就写入到 ModelError.

显然它没有允许扩展, 没有办法简单的继承 override 去 customize 它. 另外还有一个难题.

它有 Dependency Injection 的, 但是 formatter 是不允许有 Dependency Injection 的

想要依赖 service 需要靠 context 去拿才正确.

怎么办... wrap 它!

能想到的方法就是 wrap 它来 customize

public class MyInputFormatter : TextInputFormatter, IInputFormatterExceptionPolicy
{
    public MyInputFormatter()
    {
        SupportedEncodings.Add(UTF8EncodingWithoutBOM);
        SupportedEncodings.Add(UTF16EncodingLittleEndian);
        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/json").CopyAsReadOnly());
        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/json").CopyAsReadOnly());
        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/*+json").CopyAsReadOnly());
    }
    InputFormatterExceptionPolicy IInputFormatterExceptionPolicy.ExceptionPolicy => InputFormatterExceptionPolicy.MalformedInputExceptions;

    public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
    {
        var serviceProvider = context.HttpContext.RequestServices;
        var logger = serviceProvider.GetRequiredService<ILogger<SystemTextJsonInputFormatter>>();
        var jsonOptionsAccessor = serviceProvider.GetRequiredService<IOptions<JsonOptions>>();
        var defaultFormatter = new SystemTextJsonInputFormatter(jsonOptionsAccessor.Value, logger);
        InputFormatterResult result;
        if (StgUtil.HasImplementInterface(context.ModelType, typeof(IUnderPosting)))
        {
            context.HttpContext.Request.EnableBuffering();
            result = await defaultFormatter.ReadRequestBodyAsync(context, encoding);
            context.HttpContext.Request.Body.Position = 0;
            var model = (IUnderPosting)result.Model!;
            using var document = await JsonDocument.ParseAsync(context.HttpContext.Request.Body);
            context.HttpContext.Request.Body.Position = 0;
            var rootElement = document.RootElement;
            var properties = rootElement.EnumerateObject().Select(p => p.Name).ToList();
            model.UnderPostingPropertyNames = properties;
        }
        else {
            result = await defaultFormatter.ReadRequestBodyAsync(context, encoding);
        }
        return result;
    }
}

这样我们就可以控制最终的 binding 结果了. 这里只是一个大概, 具体实现我懒得写了. 还有一个需要注意的是 body stream 默认只能 read 一次, 如果要多次需要打开设定. 

参考: 

Re-reading ASP.Net Core request bodies with EnableBuffering()

How to read request body in an asp.net core webapi controller?

Stream 基础和常用

 

posted @ 2021-12-05 12:51  兴杰  阅读(124)  评论(0编辑  收藏  举报