Net Core MVC API路由URL编码%20导致空指针

Net Core MVC API Route URL Encoding %20 Causes Null Pointer

我有一个简单的 API 控制器,它接受字符串值来标识要删除的项目。因此,对 /name/foo 的 DELETE 调用将按预期删除 foo

测试时,我发现 space %20 的 URL 编码从客户端作为 第一个也是唯一的 传递路由中最后一段的字符(如 /name/%20)导致服务器响应 500。当包含其他字符时,如 /name/first%20last,这些值将正确传递给 DeleteByName()方法 first last,没有问题。

调试时,我发现 /name/%20 的 DELETE 作为 DeleteByName() 方法执行的有效路径通过了框架验证,但 name 变量是 null 并抛出空指针。我已经通过包含空检查解决了空指针问题,但这并不是最佳实践,也没有解释为什么会发生这种情况。

这是一些示例代码

    [Route("api/[controller]")]
    [ApiController]

        ...

        // DELETE: api/name
        [HttpDelete("{name}")]
        public async Task<IActionResult> DeleteByName(string name)
        {
            if (name == null) // this check handles the error, but doesn't explain why it happens
            {
                return NotFound();
            }

            await person.Delete(name); // without the null check, a null pointer is thrown on 'name'

            return Ok();
        }

为什么是 null,而不是带有一个 space 的字符串?

有没有更好的方法来处理这个问题? (就像来自框架;不需要在方法体中进行空检查)

因为你混淆了 FromQueryFromRoute。默认值为 [FromQuery]。您需要将其指定为 [FromRuote]。 如果您希望它来自除查询参数之外的任何地方,您需要明确指定它。

[HttpDelete("{name}")]
public async Task<IActionResult> DeleteByName([FromRoute]string name)

[HttpDelete]
public async Task<IActionResult> DeleteByName([FromQuery]string name)

所以当在 URL 中引用它时,它看起来如下

From Route -> api/Controller/1

From Query -> api/Controller?name=1

再举个例子:

[HttpGet("User/{username}/Account")]
public async Task<IActionResult> GetByUsername([FromRoute]string username)

[HttpGet("User/Account")]
public async Task<IActionResult> GetByUsername([FromQuery]string username)

我们再看看 URL:

的样子

From Route -> api/Controller/Tom/Account

From Query -> api/Controller/Account?username=Tom

主要有三个:

[FromQuery] // Get info from Query Parameters
[FromRoute] // Get Info From Route
[FromBody] // Get Information from Body

您可以在同一个端点中使用这些标签。示例:

[HttpGet("User/{username}/Account")]
public async Task<IActionResult> CreateNewAccount(
    [FromRoute]string username,
    [FromQuery]string accountId,
    [FromBody]UserInfoClass userInfo
    ){...}

URL -> api/Controller/Tom/Account?accountId=123456

好的,现在 %20 只是 url 中 space 的编码。这不是空引用的原因。您正在尝试查找查询参数,但您将其指定为路由参数。

据我所知,这是 asp.net 核心默认模型绑定预期行为。根据source codes,你会发现,它会检查字符串IsNullOrWhiteSpace,如果字符串是space,它也将是null。

如果您不想要这种行为,唯一的方法是创建自定义字符串模型活页夹。

更多关于如何创建自定义字符串模型活页夹的详细信息,您可以参考以下代码:

自定义字符串绑定器:

public class CustomStringBinder : IModelBinder
{
    private readonly TypeConverter _typeConverter;
    private readonly ILogger _logger;

    public CustomStringBinder(Type type, ILoggerFactory loggerFactory)
    {
        if (type == null)
        {
            throw new ArgumentNullException(nameof(type));
        }

        if (loggerFactory == null)
        {
            throw new ArgumentNullException(nameof(loggerFactory));
        }

        _typeConverter = TypeDescriptor.GetConverter(type);
        _logger = loggerFactory.CreateLogger<SimpleTypeModelBinder>();
    }

    /// <inheritdoc />
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }


        var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
        if (valueProviderResult == ValueProviderResult.None)
        {

             return Task.CompletedTask;
        }

        bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);

        try
        {
            var value = valueProviderResult.FirstValue;

            object model;
            if (bindingContext.ModelType == typeof(string))
            {
                // Already have a string. No further conversion required but handle ConvertEmptyStringToNull.
                if (bindingContext.ModelMetadata.ConvertEmptyStringToNull && string.IsNullOrEmpty(value))
                {
                    model = null;
                }
                else
                {
                    model = value;
                }
            }
            else if (string.IsNullOrEmpty(value))
            {
                // Other than the StringConverter, converters Trim() the value then throw if the result is empty.
                model = null;
            }
            else
            {
                model = _typeConverter.ConvertFrom(
                    context: null,
                    culture: valueProviderResult.Culture,
                    value: value);
            }

            CheckModel(bindingContext, valueProviderResult, model);

           
            return Task.CompletedTask;
        }
        catch (Exception exception)
        {
            var isFormatException = exception is FormatException;
            if (!isFormatException && exception.InnerException != null)
            {
                // TypeConverter throws System.Exception wrapping the FormatException,
                // so we capture the inner exception.
                exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException;
            }

            bindingContext.ModelState.TryAddModelError(
                bindingContext.ModelName,
                exception,
                bindingContext.ModelMetadata);

            // Were able to find a converter for the type but conversion failed.
            return Task.CompletedTask;
        }
    }

    protected virtual void CheckModel(
ModelBindingContext bindingContext,
ValueProviderResult valueProviderResult,
object model)
    {
        // When converting newModel a null value may indicate a failed conversion for an otherwise required
        // model (can't set a ValueType to null). This detects if a null model value is acceptable given the
        // current bindingContext. If not, an error is logged.
        if (model == null && !bindingContext.ModelMetadata.IsReferenceOrNullableType)
        {
            bindingContext.ModelState.TryAddModelError(
                bindingContext.ModelName,
                bindingContext.ModelMetadata.ModelBindingMessageProvider.ValueMustNotBeNullAccessor(
                    valueProviderResult.ToString()));
        }
        else
        {
            bindingContext.Result = ModelBindingResult.Success(model);
        }
    }



}

StringBinderProvider

public class StringBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (context.Metadata.ModelType == typeof(string))
        {
            var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();

            return new CustomStringBinder(context.Metadata.ModelType, loggerFactory);
        }

        return null;
    }
}

在startup.cs ConfigureServices 方法中注册活页夹:

        services.AddControllers(options=> {
            options.ModelBinderProviders.Insert(0, new StringBinderProvider());
        });

结果: