C# WebApi开发实战:从上位机数据上报到前后端分离完整指南
1. 项目概述与整体设计思路1.1 为什么要用WebApi而不是老式WebService很多做上位机、做工控、做WinForm开发的朋友早期习惯用WebService或者WCF对外提供接口这几年陆陆续续开始转到WebApi原因其实很直接WebApi是RESTful风格基于HTTP协议JSON作为默认数据格式跨平台、跨语言的兼容性远好于SOAP那套XML交互。我在实际项目里最常遇到的场景是这样的一台设备通过串口或Modbus协议采集温度、压力、转速等数据上位机程序把数据处理完之后需要把结果推送到服务器端的数据库里或者提供给Vue前端页面做实时展示。以前的做法是在服务器上装一个Windows服务上位机直接连SQL Server但这种做法受限于内网而且客户端直连数据库的安全性、并发控制都很难处理好。换成WebApi之后上位机只需要把数据封装成JSON通过HTTP请求POST到接口剩下的入库、校验、转发都交给服务端去处理整个链路清爽多了。还有一个好处是调试方便。WebApi可以直接用浏览器、Postman或者写个小工具去调用返回的JSON可读性很强。而WebService要么得去解析SOAP信封要么得在VS里加引用、生成代理类麻烦且笨重。因此我建议如果是从零起步的新项目优先考虑WebApi如果是老项目维护再评估成本。这个思路下面所有内容都基于此展开。1.2 项目整体架构与目录规划我在搭建这类项目的时候一般会严格区分职责避免把代码堆在一个Controller里。简单项目最少分成四层Controllers接收HTTP请求做参数校验调用Service层返回结果Models定义数据实体、DTO数据传输对象Services写业务逻辑比如数据校验、计算、缓存、调用第三方接口Repositories/Data负责数据库操作EF Core的DbContext放在这里对于小项目很多人会觉得分层太麻烦直接用Controller加DbContext也够用。我的经验是如果项目后续大概率要加功能、加页面、加报表那就老老实实分层前期多花半天时间后期能省好几天的改造成本。如果一个接口的业务逻辑超过50行Controller里就只做参数转换和结果封装真正的逻辑下放到Service层。同时项目还需要考虑统一返回格式。我用得比较顺手的是code message data这种结构code为0表示成功非0表示业务异常HTTP状态码则保持200。这样做的好处是前端处理逻辑简单无论是业务错误还是异常都走同一个回调不用在Vue的axios里写一堆拦截器。{ code: 0, message: success, data: { ... } }除此之外接口路由建议采用/api/[controller]的方式语义清晰方便后面接网关或者做负载均衡。Swagger从一开始就打开尤其是前后端分离开发时Swagger就是你最好的接口文档。2. 环境准备与项目初始化2.1 开发环境与SDK版本选择框架版本这里先说结论新项目建议直接用 .NET 8如果工作环境里有老系统依赖最低底线也要用 .NET 6因为 .NET 6 是LTS版本官方支持周期到2024年11月.NET 8的支持周期到2026年11月。千万不要用 .NET 5 或 .NET 7这两个不是LTS用着用着就得被迫升级。开发工具方面我用的是Visual Studio 2022社区版就够用。如果电脑配置一般也可以用Visual Studio Code配合C# Dev Kit插件但调试体验跟VS还是有点差距尤其是断点命中、局部变量查看这些VS更顺手。另外命令行工具dotnet CLI也建议熟悉因为后面发布、创建项目、添加包引用都要用到。假设你用的是Windows系统安装好 .NET 8 SDK 之后可以在命令行里验证版本dotnet --version如果显示的是8.x.x环境就准备好了。VS 2022 在新建项目时会自动检测到本机安装的SDK不需要额外配置。2.2 用dotnet CLI快速创建WebApi项目创建项目最快的方式是命令行干净直接不嵌套一堆模板代码。打开PowerShell或者CMD切到你想要的工作目录执行下面这条命令dotnet new webapi -n SimpleWebApi --use-controllers这里我专门加了--use-controllers参数是因为 .NET 8 的WebApi模板默认走的是Minimal API最小API模式。Minimal API写小接口确实很爽但项目一大还是Controller模式更符合传统习惯路由、过滤器、依赖注入用起来都更规范尤其适合之前写惯WebService的朋友上手。创建完成后的目录结构大概这样SimpleWebApi/ ├── Controllers/ ├── Models/ ├── Properties/ ├── Program.cs ├── appsettings.json └── SimpleWebApi.csproj然后打开工程文件SimpleWebApi.csproj里面默认引入了Swashbuckle.AspNetCore的Swagger包用于生成接口文档后面调试会很有用。先别急着删保留它。我习惯把默认生成的样板文件精简掉比如那个WeatherForecastController直接删掉自己重新建一个符合业务的Controller。这样代码干净也更容易让新手理解整个请求流转链路。3. 核心实现细节与实操步骤3.1 编写第一个Controller从Hello World到真实业务接下来我以一个实际案例来演示设备数据上报接口。这是上位机场景里最常见的需求——设备侧把温度、湿度、震动值等数据周期性地POST到WebApi服务端持久化到数据库然后Web前端可以查询展示。先定义一个DTO用来接收设备上报的数据namespace SimpleWebApi.Models { public class DeviceDataDto { public string DeviceId { get; set; } // 设备ID public DateTime Timestamp { get; set; } // 数据采集时间 public double Temperature { get; set; } // 温度 public double Humidity { get; set; } // 湿度 public double Vibration { get; set; } // 震动 } }然后新建一个Controllerusing Microsoft.AspNetCore.Mvc; using SimpleWebApi.Models; namespace SimpleWebApi.Controllers { [ApiController] [Route(api/[controller])] public class DeviceController : ControllerBase { [HttpPost(report)] public IActionResult Report(DeviceDataDto data) { if (data null || string.IsNullOrEmpty(data.DeviceId)) { return Ok(new { code 1, message 设备ID不能为空 }); } // 这里写上真正的业务逻辑入库、缓存、推送等 // 为了方便演示先直接返回接收到的数据 return Ok(new { code 0, message success, data data }); } [HttpGet(status/{deviceId})] public IActionResult GetStatus(string deviceId) { // 模拟查询设备状态 var result new { DeviceId deviceId, Status online, LastReportTime DateTime.Now }; return Ok(new { code 0, message success, data result }); } } }这里有个细节[HttpPost(report)]里面那个report我故意加了路由段这样最终接口地址是/api/Device/report而不是/api/Device。加一个动作名字一眼就能看出接口用途后面接网关或者是别人阅读代码时友好得多。路由命名规则我建议是[HttpPost(动作名)]全部用小写多个单词用连字符或驼峰团队内统一就好。启动项目后Swagger页面会自动列出这两个接口。Swagger支持直接在页面上测试请求填入JSON、点Execute就能看到响应这个比Postman还方便因为调试的时候不需要手动拼URL和请求头。如果Swagger没自动打开可以去https://localhost:端口/swagger访问。3.2 参数绑定与模型验证把接口做扎实接口写多了就会发现参数校验是前后端联调里最耗时的一块。如果后端不校验恶意或非法数据数据库里很容易混入脏数据前端拿到奇怪数据还会报错。所以我建议从第一天起就加数据注解而不是等到出问题再补。C#里做字段校验最简单的方式是用System.ComponentModel.DataAnnotationspublic class DeviceDataDto { [Required(ErrorMessage 设备ID不能为空)] [StringLength(50, ErrorMessage 设备ID长度不能超过50)] public string DeviceId { get; set; } [Required(ErrorMessage 采集时间不能为空)] public DateTime Timestamp { get; set; } [Range(-50, 200, ErrorMessage 温度超出合理范围)] public double Temperature { get; set; } [Range(0, 100, ErrorMessage 湿度超出合理范围)] public double Humidity { get; set; } }然后在Controller里检查ModelState[HttpPost(report)] public IActionResult Report(DeviceDataDto data) { if (!ModelState.IsValid) { var errors ModelState.Values .SelectMany(v v.Errors) .Select(e e.ErrorMessage) .ToList(); return Ok(new { code 1, message string.Join(; , errors) }); } // 业务处理... }这里我故意用了return Ok而不是return BadRequest是为了和前端统一约定好返回格式。HTTP状态码如果一直是200前端axios的响应拦截器就只要处理一个分支如果混用400、500前端代码就得针对不同状态码做多层判断麻烦且容易漏。这是我个人比较推荐的做法当然如果你们团队有统一的接口规范以规范为准。另外一个容易被忽略的问题是Controller直接接收的实体类型不推荐把数据库实体直接暴露给前端。上面例子里的DTO是单独定义的没有跟EF Core的实体混在一起。原因很简单前端不需要的字段比如内部编号、软删除标记不该返回否则既浪费流量又暴露内部结构反过来说前端传过来的字段数据库实体里没有的也得提前过滤掉。DTO就是前后端之间的一层契约该有的有不该有的一点都别留。3.3 数据库接入与EF Core集成对于简单项目数据库我通常先上SQLite零安装、文件即库开发联调时特别爽。等正式部署到服务器时再换SQL Server或者PostgreSQL只要Provider换一下连接字符串改一下就行。EF Core的代码基本不用动。先安装两个NuGet包dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.EntityFrameworkCore.Tools然后在appsettings.json里写连接字符串{ ConnectionStrings: { Default: Data Sourceapp.db } }接着定义一个DbContextusing Microsoft.EntityFrameworkCore; using SimpleWebApi.Models; namespace SimpleWebApi.Data { public class AppDbContext : DbContext { public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } public DbSetDeviceRecord DeviceRecords { get; set; } } }在Program.cs里注册using Microsoft.EntityFrameworkCore; using SimpleWebApi.Data; var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); builder.Services.AddDbContextAppDbContext(options options.UseSqlite(builder.Configuration.GetConnectionString(Default))); var app builder.Build(); // 自动创建数据库表 using (var scope app.Services.CreateScope()) { var db scope.ServiceProvider.GetRequiredServiceAppDbContext(); db.Database.EnsureCreated(); } if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseAuthorization(); app.MapControllers(); app.Run();注意EnsureCreated()的用法它只适合开发阶段快速建库如果表结构有变更它不会去迁移已有表必须删库重建。正规做法是用EF Core的Migration后续需要维护真实数据的时候一定要切换成dotnet ef migrations add Init dotnet ef database update这里顺带提一个重要细节EF Core在查询大数据量时默认是“延迟加载”还是“立即加载”如果你在实体上定义了导航属性比如DeviceRecord里有个DeviceType导航属性查询时如果不加Include导航属性就是null。很多新手在这个问题上栽坑明明表里有数据查出来却是空的原因就是没加Include。EF Core官方教程里会把这个讲清楚我在这里只提醒一句要连表查询就显式用 Include别指望默认行为会自动帮你去查.3.4 JWT认证鉴权给接口加上权限保护接口如果只是内网用不加鉴权可能问题不大。但如果要对公网提供服务或者接口涉及设备信息、生产数据就必须做登录认证。我用的是JWTJSON Web Token好处是无状态、适合前后端分离、跨域友好。先安装包dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer在appsettings.json中配置JWT参数{ Jwt: { Issuer: SimpleWebApi, Audience: SimpleWebApiClient, SecretKey: 这是一个自定义的密钥请用随机字符串至少16位以上, ExpireMinutes: 120 } }在Program.cs中添加认证服务using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; // 添加认证服务 builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options { options.TokenValidationParameters new TokenValidationParameters { ValidateIssuer true, ValidIssuer builder.Configuration[Jwt:Issuer], ValidateAudience true, ValidAudience builder.Configuration[Jwt:Audience], ValidateIssuerSigningKey true, IssuerSigningKey new SymmetricSecurityKey( Encoding.UTF8.GetBytes(builder.Configuration[Jwt:SecretKey]) ), ValidateLifetime true, ClockSkew TimeSpan.FromMinutes(1) }; }); // 注意必须启用认证中间件 app.UseAuthentication(); app.UseAuthorization();再写一个登录接口用于发Tokenusing System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; [ApiController] [Route(api/[controller])] public class AccountController : ControllerBase { private readonly IConfiguration _config; public AccountController(IConfiguration config) { _config config; } [HttpPost(login)] public IActionResult Login(string username, string password) { // 这里做真实的用户校验建议用加密存储密码参考数据保护API if (username ! admin || password ! 123456) { return Ok(new { code 1, message 用户名或密码错误 }); } var claims new[] { new Claim(ClaimTypes.Name, username), new Claim(ClaimTypes.Role, admin) }; var key new SymmetricSecurityKey( Encoding.UTF8.GetBytes(_config[Jwt:SecretKey])); var creds new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token new JwtSecurityToken( issuer: _config[Jwt:Issuer], audience: _config[Jwt:Audience], claims: claims, expires: DateTime.Now.AddMinutes( Convert.ToDouble(_config[Jwt:ExpireMinutes])), signingCredentials: creds ); return Ok(new { code 0, message success, data new JwtSecurityTokenHandler().WriteToken(token) }); } }希望受保护的接口比如上面那个设备数据上报直接加[Authorize]特性就行[Authorize] [HttpPost(report)] public IActionResult Report(DeviceDataDto data) { // ... }HTTP请求头里需要带Authorization: Bearer tokenSwagger页面上可以点右上角的Authorize按钮填入Token后后续请求自动带上。说一个JWT在实际项目中容易踩的坑过期时间不能设太长也不能设太短。太长Token泄露了风险很大太短用户操作到一半就掉线体验极差。我一般设120分钟然后前端在过期前用刷新Token接口换个新Token。刷新Token的机制说起来又是一篇文章这里先留个引子后面有机会专门写。3.5 文件上传与下载中文文件名编码问题实战WebApi经常要处理文件上传和下载特别是上位机场景设备日志、报表文件、图片抓拍都要通过接口传输。先看上传接口[HttpPost(upload)] public async TaskIActionResult Upload(IFormFile file) { if (file null || file.Length 0) { return Ok(new { code 1, message 文件不能为空 }); } var uploadDir Path.Combine(Directory.GetCurrentDirectory(), uploads); if (!Directory.Exists(uploadDir)) { Directory.CreateDirectory(uploadDir); } // 为避免文件名冲突用Guid做文件名原文件名存数据库 var ext Path.GetExtension(file.FileName); var fileName Guid.NewGuid().ToString(N) ext; var filePath Path.Combine(uploadDir, fileName); using (var stream new FileStream(filePath, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok(new { code 0, message success, data new { FileName fileName, Size file.Length } }); }这里建议保存文件时不管用户上传的是什么文件名服务端都统一改成Guid文件名真正的原始文件名存到数据库字段里。好处很明显避免特殊字符导致路径异常避免同名文件互相覆盖也规避了文件名路径遍历攻击的风险。再来看下载接口这是和前端Vue联调时最容易翻车的地方因为中文文件名在跨平台传输时会出现乱码[HttpGet(download/{fileName})] public IActionResult Download(string fileName) { var uploadDir Path.Combine(Directory.GetCurrentDirectory(), uploads); var filePath Path.Combine(uploadDir, fileName); if (!System.IO.File.Exists(filePath)) { return Ok(new { code 1, message 文件不存在 }); } var fileBytes System.IO.File.ReadAllBytes(filePath); var fileDownloadName 设备数据报表.csv; // 关键对中文文件名进行Url编码否则PDF、Word等文件在部分浏览器中会乱码 var encodedFileName Uri.EscapeDataString(fileDownloadName); return File( fileBytes, application/octet-stream, fileDownloadName ); }具体来说File方法会自动处理Content-Disposition头但不同浏览器对中文文件名的解析规则不太一样有的浏览器会把文件名当Latin-1解码导致“设备数据报表.csv”变成乱码。所以我更推荐在前端用Blob对象接文件流然后用URL.createObjectURL加a.download属性来触发下载文件名由前端完全控制这样中文文件名就不会乱码。这块在处理“怎么保持文件名不变 blob”这个痛点时尤其管用。axios.get(/api/Device/download/report20231010.csv, { responseType: blob }) .then(response { const blob new Blob([response.data]); const url window.URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.setAttribute(download, 设备数据报表.csv); document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); });这里有个细节responseType: blob必须加上否则axios会把文件内容当字符串处理下载下来的文件是坏的。我第一次联调时忘了这个文件下载下来只有几百字节打开就报错排查了半天才发现是responseType的问题。3.6 与Vue前端联调CORS跨域配置前后端分离开发时Vue跑在http://localhost:5173WebApi跑在http://localhost:5000端口不一样就产生了跨域。解决方式是在后端启用CORS。在Program.cs里注册CORS服务builder.Services.AddCors(options { options.AddPolicy(AllowVue, policy { policy.WithOrigins(http://localhost:5173) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); }); }); // 启用CORS中间件注意位置在 MapControllers 之前 app.UseCors(AllowVue);开发阶段如果不想维护具体域名列表可以直接AllowAnyOrigin()但上线前一定要收紧只允许正式前端的域名。不然任何人从别的域名都能调用你的接口JWT也拦不住因为Token是在请求头里的CSRF防御又得靠另外的机制。CORS这里有个巨坑如果你同时使用了AllowCredentials()和AllowAnyOrigin()浏览器会直接拒绝请求因为AllowAnyOrigin等于*不能和证书Cookie共享共存。很多新手在前后端分离开发时用axios默认带Cookie的模式就会撞上这个问题。我推荐的做法是开发阶段AllowAnyOrigin()需要带Cookie的请求场景最后再评估。3.7 扩展文本与图片分层输出到PDFitext7实战顺带说一个实际项目里我很常用的功能把WebApi查询到的数据生成PDF报表而且是文本显示在指定矩形框内图片单独分层输出。这个需求在医院报告、检测报告、工业检测单里很常见C#生态里我推荐itext7。先安装dotnet add package itext7核心思路是先画一个“模板矩形”把文本内容填进去再在页面上定位图片位置通过SetFixedPosition手动控制坐标using iText.Kernel.Pdf; using iText.Layout; using iText.Layout.Element; using iText.Layout.Properties; using iText.Kernel.Geom; public byte[] GenerateReportPdf(ReportDto report) { using var ms new MemoryStream(); var writer new PdfWriter(ms); var pdf new PdfDocument(writer); var document new Document(pdf); // 设置页面大小为A4 pdf.SetDefaultPageSize(PageSize.A4); // 添加标题文本 document.Add(new Paragraph(report.Title)) .SetFontSize(16) .SetBold() .SetTextAlignment(TextAlignment.CENTER); // 第一段内容放在指定矩形框内 var contentRect new Rectangle(50, 600, 500, 100); var paragraph new Paragraph(report.Content) .SetFixedPosition(50, 600, 500) .SetFontSize(12); document.Add(paragraph); // 在指定坐标添加图片 if (report.ImageData ! null) { var imageData iText.IO.Image.ImageDataFactory.Create(report.ImageData); var image new Image(imageData) .SetFixedPosition(50, 400) .ScaleToFit(200, 200); document.Add(image); } document.Close(); return ms.ToArray(); }SetFixedPosition接收的坐标基准是PDF页面左下角单位是磅1磅 1/72英寸。A4纸尺寸是595 x 842磅。如果你换算不清楚就记住数值越大越靠上或越靠右。这个API在实际排版时非常有用文字可以精确落到指定区域的矩形框内不会出现内容挤压。4. 常见问题与排查技巧实录4.1 循环数据采集与UI刷新卡顿这个问题在热词中出现频率极高“C# 循环数据采集和UI刷新卡顿”。很多上位机开发者的操作方式是在定时器里直接做数据采集采集完又直接更新界面上的TextBox。串口或网口通信本身是阻塞的一旦采集过程中数据没及时读完UI线程就被卡住了。解决思路是数据采集线程与UI线程分离。采集端用后台的Task.Run或专用采集线程把采集结果丢到队列或Channel里然后UI线程通过定时器或async/await去读取。一个典型的上位机数据上报到WebApi的案例后台采集循环用while加CancellationTokenprivate readonly Channeldouble _dataChannel Channel.CreateUnboundeddouble(); // 后台采集线程 private async Task StartCollectingAsync(CancellationToken token) { await Task.Run(async () { while (!token.IsCancellationRequested) { // 从Modbus、串口等读取数据 double value ReadFromDevice(); await _dataChannel.Writer.WriteAsync(value, token); await Task.Delay(100, token); // 采集间隔100ms } }, token); } // UI更新定时器 private async void Timer_Tick(object? sender, EventArgs e) { while (_dataChannel.Reader.TryRead(out double value)) { textBox1.AppendText(${value:F2}\r\n); } await Task.Delay(50); // 降低UI刷新频率 }核心原则别在UI线程里做阻塞操作别让UI刷新频率大于人眼能感知的帧率。60Hz的刷新率已经是极限了一般200ms左右刷一次用户就觉得足够流畅。如果你为了UI刷新把采集间隔降低到10ms最后只会是UI越卡、数据越丢。4.2 C#截取字符串的几个实用场景WebApi开发中经常要截取字符串比如截取JSON字段、截取时间戳、截取文件名。热词里也提到“c# 语言怎样截取字符串”。这里说几个实际项目中最常用的姿势。Substring是最基础的但很多新手容易在越界时报错。比如从字符串中提取中间部分先定位关键索引string str device_id: A1001, temperature: 23.5; int startIdx str.IndexOf(device_id: ) device_id: .Length; int endIdx str.IndexOf(,, startIdx); string deviceId str.Substring(startIdx, endIdx - startIdx);更省心的是用C# 8.0之后引入的Range特性对数组、字符串都适用string s Hello, World; string hello s[..5]; // Hello string world s[7..]; // World string middle s[^5..^1]; // Worl倒数第5个到倒数第1个还有正则表达式Regex.Match在接口日志中提取关键字段时极其好用var match Regex.Match(log, temperature(\d\.?\d*)); if (match.Success) { double temperature double.Parse(match.Groups[1].Value); }我的建议是固定格式字符串用Split或Range不固定格式、有一定规律的用正则。正则虽然难读但处理日志、输出解析这类场景它是唯一可靠的办法。4.3 WinForm中测试WebApi接口的小工具有时候接口写好了临时不想开Swagger也不想开Postman就直接在WinForm里写个小工具测试。热词里也有“c# 上位机 winfrom 中测试webapi接口”。我通常这样做using var httpClient new HttpClient(); httpClient.DefaultRequestHeaders.Add(Authorization, $Bearer {token}); var payload new { DeviceId A1001, Timestamp DateTime.Now, Temperature 25.6, Humidity 45.2 }; var json System.Text.Json.JsonSerializer.Serialize(payload); var content new StringContent(json, Encoding.UTF8, application/json); var response await httpClient.PostAsync(http://localhost:5000/api/Device/report, content); var responseText await response.Content.ReadAsStringAsync(); textBoxResponse.Text responseText;这里有一个容易踩的坑HttpClient建议复用不要每次请求都new一个否则高并发下会耗尽Socket端口。如果你的WinForm工具里有多个测试接口建议全局声明一个静态HttpClient或者用IHttpClientFactory管理。对于简单工具一个静态字段就够了。4.4 发布部署与端口占用问题开发完成后要发布上线。我常用的发布命令是dotnet publish -c Release -o ./publish发布完的文件夹里有WebApi的exe、dll、配置文件。部署到Windows服务器上有两种常见方式直接运行exe适合临时验证注册为Windows服务适合常驻后台注册Windows服务需要额外装一个Microsoft.Extensions.Hosting.WindowsServices包然后在Program.cs里加一句builder.Host.UseWindowsService();端口占用是部署时最容易遇到的问题。如果我启动后看到bind: An attempt was made to access a socket in a way forbidden by its access permissions通常就是端口被占了。netstat -ano | findstr :5000 taskkill /PID 进程ID /F在appsettings.json里也可以指定监听端口{ Urls: http://0.0.0.0:5000 }0.0.0.0表示监听所有网卡局域网内的其他机器也能访问。4.5 版本兼容性问题的处理建议热词里有一个关于VS版本兼容的问题“vs2019开发的c#上位机源码程序能用vs2015打开吗”。这个答案基本是否定的尤其是.NET Core/.NET 5的项目VS2015最高只支持到.NET Framework和.NET Core 1.x根本无法打开VS2019创建的CSProject文件。如果你拿到的老项目是VS2015创建的用它打开VS2019新建的解决方案最常见的问题是Microsoft.NET.Sdk.Web这个Sdk样式在VS2015里不被支持。处理方案有两条一是升级到VS2022社区版二是把解决方案降级但后者非常痛苦需要改csproj格式、改依赖包的版本不建议做。我的统一建议是新老项目交接时先确认开发环境统一。不管对方用的是哪个版本的VS或.NET SDK先在本地跑通dotnet build再讨论代码。用命令行工具编译是判断兼容性最快捷的方式因为IDE的报错往往被各种缓存干扰。4.6 搭建过程中踩过的几个典型坑再分享几个实际踩过、网上资料又不算多的坑。第一个坑是Swagger不显示Controller中内容中的注释。解决办法是在csproj里启用XML文档生成并在Program.cs中配置Swagger读取XML文件PropertyGroup GenerateDocumentationFiletrue/GenerateDocumentationFile NoWarn$(NoWarn);1591/NoWarn /PropertyGroup第二个坑是EF Core连接SQL Server时出现 “No process is on the other end of the pipe” 或 “Login failed” 这类错误多半是SQL Server没有开启TCP/IP协议或者连接字符串里的数据库用户名密码错了。遇到这类问题先在SSMS里用同一个账号测试连接可以快速缩小范围。第三个坑是发布后静态文件找不到。如果你在WebApi里提供了静态文件比如图片上传后的访问需要启用UseStaticFilesapp.UseStaticFiles();并且把上传目录的物理路径映射成虚拟路径访问。否则你在开发环境能访问到图片发布后怎么都404。5. 结合上位机场景的完整联调案例前几节内容虽然分块讲但还是有不少读者反馈拿到真实项目场景时不知道如何把碎片化的知识点串起来。这一节我拿一个相对完整的案例走一遍全流程方便你直接“抄作业”。需求描述有一台PLC通过Modbus TCP协议采集水温数据C#上位机负责把温度值实时读取出来然后每隔5秒把最新数据通过WebApi上报到服务器。Vue前端页面要能查看设备当前温度和历史温度曲线。分析上位机需要用到Modbus通信库我用的是NModbus4WebApi端只需要保存上报记录并提供查询最近24小时数据的接口Vue端用ECharts绘图展示曲线。5.1 上位机侧数据采集与上报这里我用一个简单的实现示意。重点不是Modbus通信本身而是数据怎么从串口/Modbus流里取出来再POST到WebApi。using Modbus.Device; using System.Net.Sockets; public class PlcReader { private TcpClient _tcpClient; private ModbusIpMaster _master; public PlcReader(string ip, int port) { _tcpClient new TcpClient(ip, port); _master ModbusIpMaster.CreateIp(_tcpClient); } public float ReadWaterTemperature(byte unitId, ushort startAddress) { // 读取保持寄存器Modbus的浮点数一般占两个寄存器 ushort[] registers _master.ReadHoldingRegisters(unitId, startAddress, 2); // 这里根据PLC的高低字节顺序转换通常是大端模式 uint raw (uint)((registers[0] 16) | registers[1]); float temp BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); return temp; } }采集之后通过HttpClient上报的代码可以参考前面WinForm小工具的写法这里不再重复。只提一个重点上报统一走同一个队列避免频繁创建HttpClient。设备采集是高频操作每5秒一次不算高但如果未来扩展为多个设备、每个设备多个点位HTTP请求数会成倍增长。做好队列削峰对接口稳定性很有帮助。5.2 WebApi端数据接收与查询接口WebApi端接口我已经在前面写过上报和查询这里给出一个补充的查询接口用于Vue端曲线的数据聚合[HttpGet(history/{deviceId})] public async TaskIActionResult GetHistory(string deviceId, DateTime start, DateTime end) { var records await _context.DeviceRecords .Where(r r.DeviceId deviceId r.Timestamp start r.Timestamp end) .OrderBy(r r.Timestamp) .Select(r new { r.Timestamp, r.Temperature }) .ToListAsync(); return Ok(new { code 0, message success, data records }); }注意这里用了异步方法ToListAsync查询数据库时释放线程占用高并发下的吞吐量要比同步查询好很多。很多新手第一次写EF Core查询时习惯用ToList()如果接口QPS上去了会发现CPU和线程数异常。异步改造的成本很低但收益明显。5.3 前端页面与ECharts展示Vue端用ECharts画历史曲线是非常常规的操作。核心代码const response await axios.get(/api/Device/history/A1001, { params: { start: 2023-10-10 00:00:00, end: 2023-10-10 23:59:59 } }); const times response.data.data.map(d d.timestamp); const temps response.data.data.map(d d.temperature); chart.setOption({ xAxis: { type: category, data: times }, yAxis: { type: value, name: 温度(°C) }, series: [{ type: line, data: temps, areaStyle: {} }] });到这里从设备采集到数据库存储再到前端展示的完整链路就串起来了。6. 发布与上线前检查清单项目写完后上线之前我有一份自己的检查清单分享给各位参考appsettings.json中的密钥是否已经从源码中剥离至少应该放到环境变量或者用户机密里。数据库连接字符串是否指向正式环境Swagger是否已经关闭或者仅限内网环境访问CORS的域名是否收紧为正式域名JWT的过期时间是否符合业务需求文件上传目录的权限是否配置正确目录是否做了越权访问控制是否启用了HTTPS尤其是有登录接口的服务明文传输密码是不可接受的。是否有统一的异常处理中间件还是默认的把异常细节全部返回给调用方最后这一点值得展开说。很多初学者上线的时候如果接口内部报错默认的ASP.NET Core行为是把Exception详细信息直接显示出来堆栈信息全都暴露给前端这对攻击者来说是很大的一份礼物。所以我建议加一个简单的全局异常中间件app.UseExceptionHandler(appError { appError.Run(async context { context.Response.ContentType application/json; context.Response.StatusCode 200; var errorResponse new { code 500, message 服务器开小差了请稍后再试 }; await context.Response.WriteAsJsonAsync(errorResponse); }); });开发环境下可以保留详细异常信息但发布环境一定用这种统一异常响应。另外一个容易忽视的点是JWT的签名密钥千万别直接写在代码里。我见过有人把密钥硬编码到Controller里一旦源码泄露所有Token可以随意伪造。正确的做法是放到环境变量、配置中心或用户机密里且建议定期轮换。文件上传目录如果放在应用根目录下还要注意一个问题不要允许上传可执行脚本文件。如果应用部署在IIS上asp.net会把上传到站点目录下的.aspx文件当成动态脚本执行攻击者上传一个WebShell服务器就等于被拿下了。稳妥做法是上传目录放到应用目录外通过虚拟路径访问或者对文件类型做白名单校验。我用过最省心的方案是把上传的文件落到独立文件服务器数据库里只存访问URL。如果是比较小的项目落到WebApi的wwwroot之外再通过自定义中间件来读取也是一种安全的方式。写在最后从我个人的经验来看C# WebApi最吸引人的地方是生态成熟、工具链统一、跨平台能力刚好够用。不管你是做上位机还是做工业物联网还是做普通的信息管理系统这套技术栈都值得投入时间去学。如果你是自己练手我建议按文章顺序把整个项目从零到一搭起来不求功能多但要把接口、数据库、认证、文件上传这四个核心模块跑通。等你哪天要把真实业务套进来的时候会发现底子已经有了剩下的都是往里面填逻辑。最后再分享一个小技巧以后每次写Controller的时候先想清楚三个问题——这个接口谁来调用、传什么参数、返回什么结构。想清楚了再动手写代码后面的返工成本至少能降低一半。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →