ASP.NET Core自定义CSV格式化器无法输出嵌套对象属性数据
问题描述
- 技术栈:C# ASP.NET Core 开发带数据表关联关系的API,接口默认返回JSON格式
- 引入组件:
WebApiContrib.Core.Formatter.Csv实现自定义CSV格式化输出 - 异常现象:返回包含嵌套
IUserProfile对象的数据时,嵌套字段未输出对应属性值,仅输出对象类型全限定名Odysseus_API.v2.Models.IUserProfileList - 正常返回的JSON结构示例:
[ { "userId": 1275, "username": "sample", "applicationName": "sample", "email": "samples@gmail.com", "password": "samplepass1234", "iUserProfile": { "profileId": 1275, "profileName": "sample" } } ]
- 异常CSV输出示例:
UserId,Username,ApplicationName,Email,Password,IUserProfile 1275,sample,sample,sample@gmail.com,samplepass1234,Odysseus_API.v2.Models.IUserProfileList
问题根因
原CsvOutputFormatter实现存在两个核心缺陷:
- 仅遍历根层级对象的属性,遇到复杂嵌套类型时直接调用
.ToString()取值,.NET中引用类型默认ToString()返回值就是类型的全限定名,因此输出了类型字符串 - 属性名读取逻辑仅支持
JsonPropertyAttribute、DisplayAttribute,未兼容实体类上标记的DataMemberAttribute注解
修复方案
修改CsvOutputFormatter实现,增加嵌套对象递归展平逻辑,同时兼容DataMemberAttribute注解配置。
第一步:新增辅助方法
在CsvOutputFormatter类中添加以下辅助方法:
/// <summary> /// 读取属性对应的显示名称,兼容JsonProperty、DataMember、Display三种注解 /// </summary> private string GetPropertyDisplayName(PropertyInfo pi) { if (pi.GetCustomAttribute<JsonPropertyAttribute>(false)?.PropertyName is string jsonPropName) return jsonPropName; if (pi.GetCustomAttribute<DataMemberAttribute>(false)?.Name is string dataMemberName) return dataMemberName; return pi.GetCustomAttribute<DisplayAttribute>(false)?.GetName() ?? pi.Name; } /// <summary> /// 判断是否为不需要递归展开的基础值类型 /// </summary> private bool IsSimpleType(Type type) { var underlyingType = Nullable.GetUnderlyingType(type); if (underlyingType != null) type = underlyingType; return type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan) || type == typeof(Guid); } /// <summary> /// 递归获取所有展平后的属性信息,用于生成CSV表头 /// </summary> private List<(int Order, PropertyInfo Prop, string HeaderName)> GetFlattenedProperties(Type type, string parentPrefix = "") { var result = new List<(int Order, PropertyInfo Prop, string HeaderName)>(); var props = type.GetProperties() .Where(pi => !pi.GetCustomAttributes<JsonIgnoreAttribute>(false).Any() && !pi.GetCustomAttributes<IgnoreDataMemberAttribute>(false).Any()); foreach (var prop in props) { var propOrder = prop.GetCustomAttribute<JsonPropertyAttribute>(false)?.Order ?? prop.GetCustomAttribute<DataMemberAttribute>(false)?.Order ?? 0; var propName = GetPropertyDisplayName(prop); var fullHeaderName = string.IsNullOrEmpty(parentPrefix) ? propName : $"{parentPrefix}_{propName}"; if (IsSimpleType(prop.PropertyType)) { result.Add((propOrder, prop, fullHeaderName)); } else { var nestedType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType; result.AddRange(GetFlattenedProperties(nestedType, fullHeaderName)); } } return result.OrderBy(x => x.Order).ToList(); } /// <summary> /// 递归获取展平后的属性值序列,按表头顺序排列 /// </summary> private List<object?> GetFlattenedPropertyValues(object obj, List<(int Order, PropertyInfo Prop, string HeaderName)> flattenedProps) { var valueMap = new Dictionary<string, object?>(); void FillValueMap(object currentObj, string prefix) { if (currentObj == null) return; var currentType = currentObj.GetType(); foreach (var prop in currentType.GetProperties() .Where(pi => !pi.GetCustomAttributes<JsonIgnoreAttribute>(false).Any() && !pi.GetCustomAttributes<IgnoreDataMemberAttribute>(false).Any())) { var propName = GetPropertyDisplayName(prop); var fullKey = string.IsNullOrEmpty(prefix) ? propName : $"{prefix}_{propName}"; var val = prop.GetValue(currentObj); if (IsSimpleType(prop.PropertyType)) { valueMap[fullKey] = val; } else { FillValueMap(val, fullKey); } } } FillValueMap(obj, ""); return flattenedProps.Select(p => valueMap.TryGetValue(p.HeaderName, out var val) ? val : null).ToList(); }
第二步:替换原有表头和行值生成逻辑
修改WriteResponseBodyAsync方法中生成表头、遍历对象取值的代码,替换为展平逻辑:
// 原有itemType获取逻辑保持不变 Type itemType; if (type.GetGenericArguments().Length > 0) { itemType = type.GetGenericArguments()[0]; } else { itemType = type.GetElementType(); } var streamWriter = new StreamWriter(response.Body, _options.Encoding); if (_options.IncludeExcelDelimiterHeader) { await streamWriter.WriteLineAsync($"sep ={_options.CsvDelimiter}"); } // --- 替换原有表头生成逻辑 start --- var flattenedProps = GetFlattenedProperties(itemType); if (_options.UseSingleLineHeaderInCsv) { var headerValues = flattenedProps.Select(p => p.HeaderName); await streamWriter.WriteLineAsync(string.Join(_options.CsvDelimiter, headerValues)); } // --- 替换原有表头生成逻辑 end --- foreach (var obj in (IEnumerable<object>)context.Object) { // --- 替换原有取值逻辑 start --- var vals = GetFlattenedPropertyValues(obj, flattenedProps); // --- 替换原有取值逻辑 end --- string valueLine = string.Empty; foreach (var val in vals) { // 以下转义、拼接逻辑和原有实现完全一致 if (val != null) { var _val = val.ToString(); //Substitute smart quotes in Windows-1252 if (_options.Encoding.EncodingName == "Western European (ISO)") _val = _val.Replace('“', '"').Replace('”', '"'); //Escape quotes _val = _val.Replace("\"", "\"\""); //Replace any \r or \n special characters from a new line with a space if (_options.ReplaceLineBreakCharacters && _val.Contains("\r")) _val = _val.Replace("\r", " "); if (_options.ReplaceLineBreakCharacters && _val.Contains("\n")) _val = _val.Replace("\n", " "); //Check if the value contains a delimiter/quote/newline and place it in quotes if so if (_val.Contains(_options.CsvDelimiter) || _val.Contains("\"") || _val.Contains("\r") || _val.Contains("\n")) _val = string.Concat("\"", _val, "\""); valueLine = string.Concat(valueLine, _val, _options.CsvDelimiter); } else { valueLine = string.Concat(valueLine, string.Empty, _options.CsvDelimiter); } } await streamWriter.WriteLineAsync(valueLine.Remove(valueLine.Length - _options.CsvDelimiter.Length)); }
第三步:添加缺失的using引用
在代码文件顶部引入序列化相关命名空间,否则DataMemberAttribute无法识别:
using System.Runtime.Serialization;
修复后效果
修复后CSV会自动展平嵌套对象属性,输出格式如下:
UserId,Username,ApplicationName,Email,Password,IUserProfile_ProfileId,IUserProfile_ProfileName 1275,sample,sample,sample@gmail.com,samplepass1234,1275,sample
如果需要调整嵌套列名的分隔规则,修改GetFlattenedProperties方法中fullHeaderName的拼接逻辑即可。
内容的提问来源于stack exchange,提问作者Noobie
相关产品推荐
相关产品推荐

