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
的字符串?
有没有更好的方法来处理这个问题? (就像来自框架;不需要在方法体中进行空检查)
因为你混淆了 FromQuery
和 FromRoute
。默认值为 [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());
});
结果:
我有一个简单的 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
的字符串?
有没有更好的方法来处理这个问题? (就像来自框架;不需要在方法体中进行空检查)
因为你混淆了 FromQuery
和 FromRoute
。默认值为 [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());
});
结果: