前言

之前的文章有谈过关于 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 的一个执行过程.

postgre更新语句回退 posting update_ASP

这就是为什么在 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.

postgre更新语句回退 posting update_Core_02

我们可以让 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

postgre更新语句回退 posting update_JSON_03

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 里

postgre更新语句回退 posting update_ASP_04

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

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

postgre更新语句回退 posting update_ASP_05

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

postgre更新语句回退 posting update_JSON_06

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

postgre更新语句回退 posting update_Core_07

怎么办... 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 一次, 如果要多次需要打开设定.