You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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实现存在两个核心缺陷:

  1. 仅遍历根层级对象的属性,遇到复杂嵌套类型时直接调用.ToString()取值,.NET中引用类型默认ToString()返回值就是类型的全限定名,因此输出了类型字符串
  2. 属性名读取逻辑仅支持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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.02 06:31:10