C# .NET解析地理信息的两种矢量空间数据格式SHP与GeoJson

最近项目中遇到了一个需求:前端上传SHP或GeoJson文件,接口接收后对其进行解析取出投影信息和属性集合存入数据库,但是对于很大的文件时,接口先响应返回给前端,将解析任务放在后台执行。

一、前期准备

1.安装必须 NuGet 包

# .NET CLI 安装命令
dotnet add package NetTopologySuite.IO.Esri.Shapefile
dotnet add package NetTopologySuite
dotnet add package NetTopologySuite.IO.GeoJson

2.任务参数类+实体类

/// <summary>
/// Shapefile解析任务参数类
/// </summary>
public class ShapefileParseTask
{
    /// <summary>
    /// 记录Id
    /// </summary>
    public string ImportId { get; set; }

    /// <summary>
    /// 路径
    /// </summary>
    public string TempDirectoryPath { get; set; } = string.Empty;

    /// <summary>
    /// 0-SHP文件 1-GeoJSON文件
    /// </summary>
    public string DataType { get; set; }
}

/// <summary>
/// 文件导入记录表
/// </summary>
public class FileImport
{
    /// <summary>
    /// 文件类型
    /// </summary>
    public string FileType { get; set; }

    /// <summary>
    /// 状态
    /// </summary>
    public string State { get; set; }

    /// <summary>
    /// 导入时间
    /// </summary>
    public DateTime? ImportDate { get; set; }

    /// <summary>
    /// 完成时间
    /// </summary>
    public DateTime? CompleteDate { get; set; }
}

/// <summary>
/// 文件导入信息表
/// </summary>
public class FileImportInfo : BaseEntity
{
    /// <summary>
    /// 文件导入记录Id
    /// </summary>
    public string FileImportId { get; set; }

    /// <summary>
    /// 图层名
    /// </summary>
    public string LayerName { get; set; }

    /// <summary>
    /// 投影信息
    /// </summary>
    public string Projection { get; set; }
}

/// <summary>
/// 文件导入属性表
/// </summary>
public class FileImportAttribute
{
    /// <summary>
    /// 文件导入信息Id
    /// </summary>
    public string FileImportInfoId { get; set; }

    /// <summary>
    /// 属性名
    /// </summary>
    public string AttributeName { get; set; }
}

二、核心代码实现

1.异步队列实现(官方 Channel,无阻塞、线程安全)

.NET 内置,无需额外装 NuGet 包,专为异步后台任务设计,完全适配 async/await。

using System.Threading.Channels;

/// <summary>
/// 异步解析队列接口
/// </summary>
public interface IShapefileParseQueue
{
    /// <summary>
    /// 推入解析任务
    /// </summary>
    /// <param name="task"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    ValueTask QueueAsync(ShapefileParseTask task, CancellationToken cancellationToken = default);

    /// <summary>
    /// 异步等待并获取任务
    /// </summary>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    ValueTask<ShapefileParseTask> DequeueAsync(CancellationToken cancellationToken = default);
}

/// <summary>
/// 基于Channel的队列实现
/// </summary>
public class ShapefileParseQueue : IShapefileParseQueue
{
    private readonly Channel<ShapefileParseTask> _channel;

    /// <summary>
    /// 构造函数
    /// </summary>
    public ShapefileParseQueue()
    {
        var options = new UnboundedChannelOptions
        {
            SingleReader = true,
            SingleWriter = false
        };
        _channel = Channel.CreateUnbounded<ShapefileParseTask>(options);
    }

    /// <summary>
    /// 推入解析任务
    /// </summary>
    /// <param name="task"></param>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentNullException"></exception>
    public async ValueTask QueueAsync(ShapefileParseTask task, CancellationToken cancellationToken = default)
    {
        if (task == null) throw new ArgumentNullException(nameof(task));
        await _channel.Writer.WriteAsync(task, cancellationToken);
    }

    /// <summary>
    /// 异步等待并获取任务
    /// </summary>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    public async ValueTask<ShapefileParseTask> DequeueAsync(CancellationToken cancellationToken = default)
    {
        var task = await _channel.Reader.ReadAsync(cancellationToken);
        return task;
    }
}

2.后台解析服务(标准 BackgroundService,全 async 无阻塞)

using Microsoft.Extensions.Hosting;
using NetTopologySuite.IO.Esri;
using Pipelines.Sockets.Unofficial.Arenas;
using Snowflake.Core;

/// <summary>
/// Shapefile、GeoJson后台解析服务
/// </summary>
public class ShapefileParseHostedService : BackgroundService
{
    private readonly IShapefileParseQueue _queue;
    private readonly IdWorker _idWorker;
    private readonly IDataGovernanceData _dataGovernanceData;

    /// <summary>
    /// 构造函数
    /// </summary>
    /// <param name="queue"></param>
    /// <param name="idWorker"></param>
    /// <param name="dataGovernanceData"></param>
    public ShapefileParseHostedService(
        IShapefileParseQueue queue, IdWorker idWorker, IDataGovernanceData dataGovernanceData)
    {
        _queue = queue;
        _idWorker = idWorker;
        _dataGovernanceData = dataGovernanceData;
    }

    public override Task StartAsync(CancellationToken cancellationToken)
    {
        return base.StartAsync(cancellationToken);
    }

    /// <summary>
    /// 
    /// </summary>
    /// <param name="stoppingToken"></param>
    /// <returns></returns>
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // 持续监听任务,应用关闭时自动退出
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                var parseTask = await _queue.DequeueAsync(stoppingToken);
                await ProcessTaskAsync(parseTask, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception)
            {
                await Task.Delay(1000, stoppingToken);
            }
        }
    }

    /// <summary>
    /// 解析任务
    /// </summary>
    /// <param name="task"></param>
    /// <param name="stoppingToken"></param>
    /// <returns></returns>
    private async Task ProcessTaskAsync(ShapefileParseTask task, CancellationToken stoppingToken)
    {
        var importId = task.ImportId;
        var tempDir = task.TempDirectoryPath;
        var dataType = task.DataType;

        if (dataType == "0")
        {
            await ParseShpFilesAsync(importId, tempDir, stoppingToken);
        }
        else if (dataType == "1")
        {
            await ParseGeoJsonFilesAsync(importId, tempDir, stoppingToken);
        }
    }

    /// <summary>
    /// 解析 SHP 文件
    /// </summary>
    private async Task ParseShpFilesAsync(string importId, string tempDir, CancellationToken stoppingToken)
    {
        try
        {
            // 1. 文件分组
            var allFiles = Directory.GetFiles(tempDir);
            var layerGroups = allFiles
                .GroupBy(f => Path.GetFileName(f).Split('.')[0])
                .ToDictionary(g => g.Key, g => g.ToList());

            var fileImportInfoList = new List<FileImportInfo>();
            var fileImportAttributeList = new List<FileImportAttribute>();

            // 2. 遍历解析每个图层
            foreach (var group in layerGroups)
            {
                var layerName = group.Key;
                var groupFilePaths = group.Value;

                // 找到shp主文件
                var shpPath = groupFilePaths.FirstOrDefault(f => f.EndsWith(".shp", StringComparison.OrdinalIgnoreCase));
                if (string.IsNullOrEmpty(shpPath)) continue;

                // 读取投影信息 (prj)
                var prjPath = groupFilePaths.FirstOrDefault(f => f.EndsWith(".prj", StringComparison.OrdinalIgnoreCase));
                string projectionWkt = string.Empty;
                if (!string.IsNullOrEmpty(prjPath))
                {
                    projectionWkt = await File.ReadAllTextAsync(prjPath, stoppingToken);
                }

                var fileImportInfoId = _idWorker.NextId().ToString();
                fileImportInfoList.Add(new FileImportInfo
                {
                    Id = fileImportInfoId,
                    FileImportId = importId,
                    LayerName = layerName,
                    Projection = projectionWkt
                });

                var attributeFields = new List<string>();
                // 解析shp获取属性字段
                using var shapefile = Shapefile.OpenRead(shpPath);
                if (shapefile != null)
                {
                    attributeFields = shapefile.Fields.Select(field => field.Name).ToList();
                }

                if (attributeFields.Count > 0)
                {
                    attributeFields.ForEach(field =>
                    {
                        fileImportAttributeList.Add(new FileImportAttribute
                        {
                            Id = _idWorker.NextId().ToString(),
                            FileImportInfoId = fileImportInfoId,
                            AttributeName = field
                        });
                    });
                }
            }

            _dataGovernanceData.AddFileImportInfo(importId, fileImportInfoList, fileImportAttributeList);
        }
        catch (Exception)
        {
            _dataGovernanceData.UpdateFileImport(importId);
        }
        finally
        {
            // 清理临时文件
            CleanupTempDirectory(tempDir);
        }
    }

    /// <summary>
    /// 解析 GeoJSON 文件
    /// </summary>
    private async Task ParseGeoJsonFilesAsync(string importId, string tempDir, CancellationToken stoppingToken)
    {
        // 1. 找到所有 .json 或 .geojson 文件
        var geoJsonFiles = Directory.GetFiles(tempDir)
            .Where(f => f.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
                        || f.EndsWith(".geojson", StringComparison.OrdinalIgnoreCase))
            .ToList();

        if (geoJsonFiles.Count == 0)
        {
            throw new Exception("临时目录中未找到 .json 或 .geojson 文件");
        }

        try
        {
            var fileImportInfoList = new List<FileImportInfo>();
            var fileImportAttributeList = new List<FileImportAttribute>();

            // 2. 遍历解析每个 GeoJSON 文件
            foreach (var filePath in geoJsonFiles)
            {
                var fileName = Path.GetFileNameWithoutExtension(filePath); // 取文件名作为图层名

                // 3. 读取并解析 GeoJSON 内容
                var geoJsonContent = await File.ReadAllTextAsync(filePath, stoppingToken);

                var geoJsonReader = new NetTopologySuite.IO.GeoJsonReader();
                var geometry = geoJsonReader.Read<NetTopologySuite.Features.FeatureCollection>(geoJsonContent);

                var fileImportInfoId = _idWorker.NextId().ToString();
                fileImportInfoList.Add(new FileImportInfo
                {
                    Id = fileImportInfoId,
                    FileImportId = importId,
                    LayerName = fileName,
                    Projection = "EPSG:4326"
                });

                List<string> attributeNames = [];
                if (geometry.Count > 0)
                {
                    var firstFeature = geometry[0];
                    if (firstFeature.Attributes != null)
                    {
                        attributeNames = [.. firstFeature.Attributes.GetNames()];
                    }
                }

                if (attributeNames.Count > 0)
                {
                    attributeNames.ForEach(field =>
                    {
                        fileImportAttributeList.Add(new FileImportAttribute
                        {
                            Id = _idWorker.NextId().ToString(),
                            FileImportInfoId = fileImportInfoId,
                            AttributeName = field
                        });
                    });
                }
            }
            //同一事务的新增接口
            _dataGovernanceData.AddFileImportInfo(importId, fileImportInfoList, fileImportAttributeList);
        }
        catch (Exception)
        {
//更新状态接口 _dataGovernanceData.UpdateFileImport(importId); }
finally { // 清理临时文件 CleanupTempDirectory(tempDir); } } /// <summary> /// 删除文件 /// </summary> /// <param name="path"></param> private void CleanupTempDirectory(string path) { if (Directory.Exists(path)) { Directory.Delete(path, true); } } }

3.控制器代码

using Microsoft.AspNetCore.Mvc;
using Snowflake.Core;

namespace YMW.Gp.AgsInterface.Controllers
{
    /// <summary>
    /// 数据治理
    /// </summary>
    [Route("ags/[controller]/[action]")]
    public class DataGovernanceController : BaseController
    {
        private readonly IDataGovernanceService _dataGovernanceService;
        private readonly IdWorker _idWorker;
        private readonly IShapefileParseQueue _queue;

        /// <summary>
        /// 构造函数
        /// </summary>
        public DataGovernanceController(IDataGovernanceService dataGovernanceService, IdWorker idWorker, IShapefileParseQueue queue)
        {
            _dataGovernanceService = dataGovernanceService;
            _idWorker = idWorker;
            _queue = queue;
        }

        /// <summary>
        /// 提交文件导入
        /// </summary>
        /// <param name="formCollection">文件</param>
        /// <param name="dataType">数据类型 0SHP 1GeoJson 2中间库</param>
        /// <returns></returns>
        [HttpPost]
        public async Task<ActionResult<string>> UploadShapefile([FromForm] IFormCollection formCollection, string dataType)
        {
            var result = string.Empty;
            var now = DateTime.Now;

            var files = formCollection.Files;
            if (files == null || files.Count == 0) return BadRequest("未上传文件");

            var importId = _idWorker.NextId().ToString();
            var tempDir = Path.Combine(Path.GetTempPath(), "ShapefileImport", importId.ToString());
            Directory.CreateDirectory(tempDir);

            try
            {
                if (dataType == "0")
                {
                    var layerGroups = files
                        .GroupBy(f =>
                        {
                            var fileName = f.FileName;
                            var firstDotIndex = fileName.IndexOf('.');
                            // 没有点/点在首位,直接用完整文件名;否则取第一个点之前的内容作为图层名
                            return firstDotIndex <= 0 ? fileName : fileName.Substring(0, firstDotIndex);
                        })
                        .ToDictionary(g => g.Key, g => g.ToList());

                    foreach (var group in layerGroups)
                    {
                        var layerName = group.Key;
                        var layerFiles = group.Value;

                        // 校验核心文件
                        var shp = layerFiles.FirstOrDefault(f => f.FileName.EndsWith(".shp", StringComparison.OrdinalIgnoreCase));
                        var shx = layerFiles.FirstOrDefault(f => f.FileName.EndsWith(".shx", StringComparison.OrdinalIgnoreCase));
                        var dbf = layerFiles.FirstOrDefault(f => f.FileName.EndsWith(".dbf", StringComparison.OrdinalIgnoreCase));

                        if (shp == null || shx == null || dbf == null)
                            return BadRequest($"图层 [{layerName}] 缺少 shp/shx/dbf 核心文件");
                    }
                }
                else if (dataType == "1")
                {
                    var geoJsonFile = files.FirstOrDefault(f => f.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || f.FileName.EndsWith(".geojson", StringComparison.OrdinalIgnoreCase));
                    if (geoJsonFile == null)
                    {
                        return BadRequest("GeoJSON格式请上传 .json 或 .geojson 后缀的文件");
                    }
                }

                //保存文件到本地
                foreach (var file in files)
                {
                    if (file.Length <= 0) continue;
                    var savePath = Path.Combine(tempDir, file.FileName);
                    await using var fileStream = new FileStream(savePath, FileMode.Create);
                    await file.CopyToAsync(fileStream);
                }

                var importRecord = new FileImport
                {
                    Id = importId,
                    State = "0",
                    ImportDate = now,
                    FileType = dataType
                };
                 
                //插入记录接口
                var insert = _dataGovernanceService.InsertFileImport(importRecord);
                if (string.IsNullOrEmpty(insert))
                {
                    //将任务推入后台队列
                    await _queue.QueueAsync(new ShapefileParseTask
                    {
                        ImportId = importId,
                        TempDirectoryPath = tempDir,
                        DataType = dataType
                    });
                }
            }
            catch (Exception ex)
            {
                if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true);
                result = ex.ToString();
            }
            return string.IsNullOrEmpty(result) ? Ok("提交成功!") : BadRequest(result);
        }
    }
}

4.Program.cs 注册(必须严格按这个写,不能重复注册)

var builder = WebApplication.CreateBuilder(args);

// 1. 添加控制器
builder.Services.AddControllers();

// 2. 注册异步队列(单例,全局唯一,只能注册1次)
builder.Services.AddSingleton<IShapefileParseQueue, ShapefileParseQueue>();

// 3. 注册后台解析服务(必须,只能注册1次)
builder.Services.AddHostedService<ShapefileParseHostedService>();

// 4. 大文件上传配置(保留你之前的配置)
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(opt =>
{
    opt.MultipartBodyLengthLimit = long.MaxValue;
});
builder.WebHost.ConfigureKestrel(opt =>
{
    opt.Limits.MaxRequestBodySize = long.MaxValue;
});

// 5. Swagger配置(调试用,可选)
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// 开发环境启用Swagger
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

根据需求,核心解决接口快速返回 + 后台异步解析 + 状态持久化三大核心问题,采用「接口同步落库 + 文件持久化 + Channel 后台任务队列异步解析」的方案,完美解决大文件解析超时问题,同时保证任务可靠性和数据一致性。

 

posted @ 2026-03-18 14:44  挺秃然的i  阅读(2)  评论(0)    收藏  举报