在 ASP.NET Core Web API 中實現可選更新功能
TLDR
- 透過自定義
OptionalValue<T>struct,可精確區分「不更新欄位」與「更新為 null」。 - 實作
JsonConverter處理[FromBody]請求,簡化 JSON 序列化格式。 - 實作
ModelBinder處理[FromForm]請求,解決表單資料繫結問題。 - 實作
IModelValidator確保 Data Annotation 驗證機制能正確運作。 - 透過
ISchemaFilter與IOperationFilter調整 Swagger 文件,確保 API 文件顯示正確的資料結構。
在 RESTful API 的 PATCH 操作中,常見的痛點在於無法區分「不更新該欄位」與「將欄位更新為 null」。若直接使用 null 作為判斷基準,對於 DateTime 或 int 等 struct 型別將無法區分其原始狀態。本方案透過自定義 OptionalValue<T> 型別,讓後端能明確識別前端是否傳遞了該欄位。
可選屬性型別設計
什麼情況下會遇到這個問題:當 API 需要支援部分更新(Partial Update),且必須區分「欄位未傳遞(忽略)」與「欄位傳遞為 null(更新)」時。
我們使用 readonly record struct 來定義 OptionalValue<T>,利用 HasValue 屬性標記該欄位是否被賦值。
public readonly record struct OptionalValue<T> {
private readonly T value;
public OptionalValue(T value) {
HasValue = true;
this.value = value;
}
public static OptionalValue<T> Empty() => new();
[ValidateNever]
public bool HasValue { get; }
[ValidateNever]
public T Value {
get {
if (!HasValue) {
throw new InvalidOperationException("OptionalValue object must have a value.");
}
return value;
}
}
public static implicit operator OptionalValue<T>(T value) {
return new OptionalValue<T>(value);
}
public static explicit operator T(OptionalValue<T> value) {
return value.Value;
}
}FromBody 的 JsonConverter 實作
什麼情況下會遇到這個問題:當使用 [FromBody] 接收 JSON 資料時,預設序列化會將 OptionalValue<T> 視為複雜物件,導致前端必須傳遞 {"hasValue": true, "value": "..."} 格式。
透過自定義 JsonConverter,我們可以將 JSON 簡化為直接傳遞值,例如 {"string1": "Value"}。
自定義 JsonConverter 與 Factory
public class OptionalValueConverter<T> : JsonConverter<OptionalValue<T>> {
public override OptionalValue<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
if (reader.TokenType == JsonTokenType.None) {
return OptionalValue<T>.Empty();
} else {
T? value = JsonSerializer.Deserialize<T>(ref reader, options);
if (value is null && typeof(T).IsValueType && Nullable.GetUnderlyingType(typeof(T)) is null) {
throw new JsonException($"Null value is not allowed for non-nullable type {typeof(T)}.");
}
return new OptionalValue<T>(value!);
}
}
public override void Write(Utf8JsonWriter writer, OptionalValue<T> value, JsonSerializerOptions options) {
if (value.HasValue) {
JsonSerializer.Serialize(writer, value.Value, options);
}
}
}
public class OptionalValueJsonConverterFactory : JsonConverterFactory {
public override bool CanConvert(Type typeToConvert) =>
typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(OptionalValue<>);
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) {
Type type = typeToConvert.GetGenericArguments()[0];
Type converterType = typeof(OptionalValueConverter<>).MakeGenericType(type);
return Activator.CreateInstance(converterType) as JsonConverter;
}
}FromForm 的 ModelBinder 實作
什麼情況下會遇到這個問題:當使用 [FromForm] 接收表單資料時,ASP.NET Core 預設無法正確將表單欄位繫結至泛型 struct。
我們實作 IModelBinder 來處理表單資料的解析,將 key=value 的形式正確對應到 OptionalValue<T>。
public class OptionalValueModelBinder<T> : IModelBinder {
public Task BindModelAsync(ModelBindingContext bindingContext) {
ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == ValueProviderResult.None) {
bindingContext.Result = ModelBindingResult.Success(OptionalValue<T>.Empty());
return Task.CompletedTask;
}
string? valueStr = valueProviderResult.FirstValue;
Type targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
try {
TypeConverter converter = TypeDescriptor.GetConverter(targetType);
object? convertedValue = converter.CanConvertFrom(typeof(string))
? converter.ConvertFrom(valueStr!)
: Convert.ChangeType(valueStr, targetType);
bindingContext.Result = ModelBindingResult.Success(new OptionalValue<T>((T)convertedValue!));
} catch {
bindingContext.ModelState.AddModelError(bindingContext.ModelName, $"The value '{valueStr}' is invalid.");
}
return Task.CompletedTask;
}
}處理資料驗證
什麼情況下會遇到這個問題:當 DTO 屬性使用 [Required] 或 [Range] 等 Data Annotation 時,預設驗證器無法穿透 OptionalValue<T> 進行內部值的檢查。
我們實作 IModelValidator,當 HasValue 為 true 時,才對內部的 Value 進行驗證。
public class OptionalValueValidator<T> : IModelValidator {
private readonly ValidatorItem validatorItem;
public OptionalValueValidator(ValidatorItem validatorItem) => this.validatorItem = validatorItem;
public IEnumerable<ModelValidationResult> Validate(ModelValidationContext context) {
if (context.Model is OptionalValue<T> optionalValue && optionalValue.HasValue) {
if (validatorItem.ValidatorMetadata is ValidationAttribute attribute) {
if (!attribute.IsValid(optionalValue.Value)) {
yield return new ModelValidationResult("", attribute.FormatErrorMessage(context.ModelMetadata.GetDisplayName()));
}
}
}
}
}處理 Swagger Schema
什麼情況下會遇到這個問題:由於使用了自定義的 Converter 與 Binder,Swagger 自動產生的文件會顯示錯誤的結構(如顯示 hasValue 欄位)。
透過 ISchemaFilter 與 IOperationFilter,我們能強制 Swagger 顯示正確的型別與參數格式。
OptionalValueSchemaFilter:將[FromBody]的 Schema 簡化為原始型別。OptionalValueOperationFilter:將[FromForm]的參數名稱從Property.Value修正為Property。
結論與建議做法
- 透過
OptionalValue<T>封裝,能有效解決 API 部分更新的歧義問題。 - 務必在
Program.cs中依序註冊JsonConverterFactory、ModelBinderProvider與ModelValidatorProvider。 - 建議在專案中統一使用此模式處理
PATCH請求,以維持 API 介面的一致性。
異動歷程
- 2024-10-21 初版文件建立。
