尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ASP.NET Core实战:MVC与Web API共存的城市天气应用

ASP.NET Core实战:MVC与Web API共存的城市天气应用 简介基于ASP.NET Core Web API与MVC架构的天气查询示例项目面向正在学习.NET全栈开发的学生或初级工程师演示按城市名检索实时天气的完整流程。压缩包共90个文件、约6.13MB其中42个dll为运行时依赖库11个cs为控制器与模型源码10个json为配置文件另有cshtml视图、csproj/sln工程文件及README说明结构清晰便于直接打开构建。已有88人浏览学习。项目覆盖MVC分层设计、RESTful Web API构建、Entity Framework Core数据访问、HttpClient调用外部天气接口、JSON序列化与前端Ajax交互等关键知识点同时涉及异常处理与日志记录机制并讨论了IIS及云服务部署思路。对于希望从零搭建ASP.NET Core应用、深入理解Web API与MVC协同工作方式的读者这是一份难得的实战练习素材也可直接用作课程设计或毕业设计的基础原型。1. 城市天气检索一个把 MVC 与 Web API 放进同一个解决方案的典型例子技术选型遇到 MVC 和 Web API 二选一时WeatherFinder 给出了第三个答案两个都要。用户需要的是一个能输入城市名、看到实时天气的网页这个网页又不想用传统 Postback 刷新整页于是 MVC 负责渲染页面Web API 返回 JSON 数据EF Core 在背后管理城市与天气记录。很多人把它理解成“杂糅”实际上它是 ASP.NET Core 里很常见的组合同一个进程、同一套 DI 容器只是控制器职责不同。这个项目适合刚读完基础教程、想弄清页面、接口、ORM 如何配合的人也适合给全栈转 .NET 的同事当代码阅读材料。2. 先看清解决方案结构MVC 与 Web API 如何在同一个进程中共存2.1 项目布局与工具链解压WeatherFinder-main后入口是WeatherFinderApp.sln。这个解决方案里只有一个WeatherFinderApp项目并没有把 Web API 拆成独立类库这正是“简单应用”该有的形态几张表以内的功能拆多了反而要维护引用链。WeatherFinder/ ├── WeatherFinderApp.sln ├── README.md ├── LICENSE ├── .gitignore └── WeatherFinderApp/ ├── Controllers/ │ ├── HomeController.cs # 渲染 MVC 页面 │ └── WeatherController.cs # 返回 JSON 的 API 控制器 ├── Models/ # 城市、天气记录模型 ├── Data/ # EF Core DbContext 与迁移 ├── Views/ # Razor 视图 ├── appsettings.json # 连接字符串、外部 API Key └── WeatherFinderApp.csproj对照这个结构可以先做一张职责表目录/文件承载内容和普通 MVC 项目的差异Controllers/HomeController.cs打开首页时返回 View没有额外职责Controllers/WeatherController.cs暴露/api/weather/{id}用[ApiController]不返回 ViewModels/City、WeatherRecord也可以放 DTO避免把 EF 实体直接序列化Data/DbContext 和迁移脚本控制器通过构造函数注入它Views/Index.cshtml页面里只放表单和挂载点数据交给 fetch我一般会把这个目录讲给刚接触的人听真正的学习成本不在目录名字而在“同是 Controller 后缀为什么 Home 返回 ViewWeather 返回 JSON”。答案在属性路由和它们继承的不同基类上。2.2 Program.cs 里的一次注册.NET 6 之后的模板把 Startup.cs 合并进了 Program.cs。WeatherFinder 这类写法最常见的漏点是少注册、少加中间件。一个能同时跑 MVC 和 API 的最小配置如下。var builder WebApplication.CreateBuilder(args); // 一次注册MVC 控制器和带 [ApiController] 的控制器都被注册进来 builder.Services.AddControllersWithViews(); // 注入 EF Core这里以 SQLite 为例生产可换成 SqlServer builder.Services.AddDbContextWeatherFinderDbContext(options options.UseSqlite(builder.Configuration.GetConnectionString(DefaultConnection))); var app builder.Build(); if (!app.Environment.IsDevelopment()) { // 非开发环境普通 MVC 请求 500 时跳到统一错误页 app.UseExceptionHandler(/Home/Error); } app.UseStaticFiles(); app.UseRouting(); // MVC 约定路由/Home/Index app.MapControllerRoute( name: default, pattern: {controllerHome}/{actionIndex}/{id?}); // Web API 属性路由/api/weather/1 app.MapControllers(); app.Run();这段代码有两个地方不能省。AddControllersWithViews()把 MVC 与 API 的控制器一起注册进 DI 容器若只写AddControllers()Razor 视图找不到HomeController只写AddControllersWithViews()则 API 路由缺失到只剩路由映射。另一个是路由端点MapControllerRoute处理{controllerHome}/{actionIndex}/{id?}MapControllers识别[Route(api/[controller])]这类属性路由。两个端点可以同时激活因为 ASP.NET Core 内部是按路由模板匹配的不会互相抢占。UseExceptionHandler(/Home/Error)对 MVC 有用但对 API 不友好接口请求 500 时它也会返回一个 HTML 错误页。如果不加改造前端 fetch 拿到的是非 200 状态码加一页 HTML。后面第 5 章我会给一个只在/api路径生效的处理方法。2.3 为什么不同时用 Razor Pages 或纯前端这个项目最容易被问的问题是既然已经有 MVC为什么不直接在控制器里返回 JSON这就引出了 Web API 和 MVC 的边界问题。MVC 控制器返回View()时渲染的是服务端 Razor 模板同一控制器方法也可以返回Json()但项目里把它单独放到WeatherController并加[ApiController]是为了拿到框架级的自动模型校验、统一的响应约定以及更清晰的 OpenAPI 表达能力。多数情况下我建议照这个思路拆页面骨架用 MVC 渲染交互数据用 API 提供。相比单页应用免去了 CORS 配置、登录态跨域这类事相比传统 Postback又不会每次点查询都刷新整页。EF Core 的DbContext注册在同一个容器里HomeController 和 WeatherController 各自注入同一个实例范围不会出现“API 拿不到数据库连接”的问题。也就是说MVC 和 Web API 是同一屋檐下的两个租客而不是两套系统。3. EF Core 数据层城市表与天气记录用外键连起来3.1 两个模型一条关系WeatherFinder 显然不想每次查询城市都直接去外部天气服务全量扫一遍所以“城市”和“天气记录”是分开建模的。城市表保存城市名和国别/地区代码天气记录保存某一次查询的温度、湿度和描述。它们通过城市 ID 关联。namespace WeatherFinderApp.Models; public class City { public int Id { get; set; } public string Name { get; set; } string.Empty; public string CountryCode { get; set; } CN; } public class WeatherRecord { public int Id { get; set; } // 外键描述这条记录属于哪个城市 public int CityId { get; set; } public City City { get; set; } null!; public decimal TemperatureC { get; set; } public int Humidity { get; set; } public string Description { get; set; } string.Empty; public DateTime RetrievedAt { get; set; } DateTime.UtcNow; }City是主表WeatherRecord是从表。WeatherRecord.City是导航属性EF Core 用它做联表查询CityId是外键。很多人写到这里会问为什么主表里不放ListWeatherRecord作为学习项目不放集合导航属性可以少踩序列化成环的坑——当你在 Web API 里直接返回WeatherRecord对象时EF Core 可能把City也带上JSON 序列化时就会出现循环引用。后面控制器部分我还会用 DTO 再切一刀。3.2 DbContext 的配置尤其是级联删除数据访问入口是一个继承DbContext的类。源码里最常见写法如下。using Microsoft.EntityFrameworkCore; using WeatherFinderApp.Models; namespace WeatherFinderApp.Data; public class WeatherFinderDbContext : DbContext { public WeatherFinderDbContext(DbContextOptionsWeatherFinderDbContext options) : base(options) { } public DbSetCity Cities SetCity(); public DbSetWeatherRecord WeatherRecords SetWeatherRecord(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityWeatherRecord(entity { entity.HasOne(w w.City) .WithMany() .HasForeignKey(w w.CityId) .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(w new { w.CityId, w.RetrievedAt }); }); // 种子城市方便第一次启动时页面有下拉选项 modelBuilder.EntityCity().HasData( new City { Id 1, Name 上海, CountryCode CN }, new City { Id 2, Name 深圳, CountryCode CN }); } }HasOne(w w.City).WithMany()表示一个城市可以有多条天气记录WithMany()不传参数表示 City 这一侧不维护集合导航。HasForeignKey(w w.CityId)直接指定外键字段如果你不写EF Core 会按约定去找CityId但显式写出来能让迁移文件更直白。OnDelete(DeleteBehavior.Cascade)表示删除城市时它的天气记录会一起被删避免留下孤儿数据。实际生产里我有时会用Restrict这里因为是学习项目级联删更省事。HasIndex(w new { w.CityId, w.RetrievedAt })是个容易被忽略的优化点查“某城市最新天气”时排序键RetrievedAt和外键CityId都有索引后续查询走 Index Seek而不是全表扫描。数据量只有几百条时感觉不出来一旦外部 API 定时写入过几个月就会明显。3.3 迁移命令与参数解释写完模型后要让 EF Core 生成数据库。项目若还没装工具先全局安装dotnet tool install --global dotnet-ef dotnet ef migrations add InitCityWeather -o Data/Migrations dotnet ef database update第一条命令安装dotnet-ef工具第二条命令把迁移文件输出到Data/Migrations目录不写-o会默认放到项目根目录的Migrations下第三条命令读取appsettings.json里ConnectionStrings:DefaultConnection按上下文创建数据库文件。如果项目缺少Microsoft.EntityFrameworkCore.Design包第二条命令会报错需要先在 csproj 里加上它。执行后可以在Data/Migrations看到三个文件..._InitCityWeather.cs包含迁移操作..._Designer.cs包含元数据WeatherFinderDbContextModelSnapshot.cs是快照。这三个文件都要提交到代码库不要加入.gitignore。每次模型变化就再dotnet ef migrations add XxxEF Core 会在快照基础上生成增量 SQL不会重复创建已有表。3.4 控制器里如何查询城市EF Core 在控制器中的常见用法是过滤加分页。下面这个动作来自 MVC 的 Home 控制器负责给页面提供城市下拉列表public class HomeController : Controller { private readonly WeatherFinderDbContext _db; public HomeController(WeatherFinderDbContext db) { _db db; } public async TaskIActionResult Index(string q, CancellationToken ct) { var query _db.Cities.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(q)) { // 用 EF.Functions.Like 做前缀模糊 query query.Where(c EF.Functions.Like(c.Name, ${q}%)); } var cities await query .OrderBy(c c.Name) .Take(20) .Select(c new CityOption { Id c.Id, Name c.Name }) .ToListAsync(ct); return View(new IndexViewModel { Cities cities, Keyword q }); } }AsNoTracking()告诉 EF Core 不跟踪实体这里只读展示跟踪是浪费内存。EF.Functions.Like会翻译成 SQL 的LIKE而不是先拉全表再在内存里过滤。Take(20)限制返回量避免城市多了以后下拉列表被撑爆。Select到 DTO 是防止 EF Core 把整个实体连带导航属性一起序列化的第一步。这样Controller 里的查询代码没有一条手工 SQL城市列表和天气记录都走 EF Core 的表达式树迁移文件负责建表。第 4 章的 Web API 会把“查天气”这个动作接到外部数据源上。4. Web API 控制器与 MVC 视图一边返回 JSON一边 fetch 渲染4.1 Web API 端点的设计正式的服务路径是/api/weather/{cityId}。这个端点接收城市 ID返回该城市最新天气或从外部服务拉取后缓存。控制器用ControllerBase而不是Controller并用[ApiController]开启自动 400 处理。using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using WeatherFinderApp.Data; using WeatherFinderApp.Models; namespace WeatherFinderApp.Controllers; [ApiController] [Route(api/[controller])] public class WeatherController : ControllerBase { private readonly WeatherFinderDbContext _db; private readonly IHttpClientFactory _httpClientFactory; public WeatherController( WeatherFinderDbContext db, IHttpClientFactory httpClientFactory) { _db db; _httpClientFactory httpClientFactory; } [HttpGet({cityId:int})] public async TaskActionResultWeatherDto Get(int cityId, CancellationToken ct) { var city await _db.Cities.AsNoTracking() .FirstOrDefaultAsync(c c.Id cityId, ct); if (city is null) { // 不会往下走直接返回 404 JSON return NotFound(new ProblemDetails { Title 城市不存在, Status StatusCodes.Status404NotFound }); } var latest await _db.WeatherRecords.AsNoTracking() .Where(w w.CityId cityId) .OrderByDescending(w w.RetrievedAt) .FirstOrDefaultAsync(ct); if (latest is null || DateTime.UtcNow - latest.RetrievedAt TimeSpan.FromMinutes(30)) { latest await FetchFromExternalServiceAsync(city, ct); } return Ok(new WeatherDto { CityId city.Id, CityName city.Name, TemperatureC latest.TemperatureC, Humidity latest.Humidity, Description latest.Description, RetrievedAt latest.RetrievedAt }); } }这里有几个值得说透的参数细节。Route(api/[controller])里的[controller]会被替换成控制器名去掉Controller也就是Weather所以最终端点是/api/weather/{cityId}而不必手写api/weather。{cityId:int}加了类型约束访问/api/weather/abc时会被路由跳过返回 404 而不是进到方法里再解析。AsNoTracking().FirstOrDefaultAsync(...)读城市和天气记录都关闭跟踪因为这两个对象只做读取和转化不会修改。TimeSpan.FromMinutes(30)是缓存窗口实际项目里我会把它放到appsettings.json的配置项里方便调但示例代码直接写死更直观。ProblemDetails是 ASP.NET Core 内置的错误响应格式前端 fetch 可以根据status字段判断错误类型。外部服务FetchFromExternalServiceAsync的简化实现可以依赖IHttpClientFactoryprivate async TaskWeatherRecord FetchFromExternalServiceAsync(City city, CancellationToken ct) { var client _httpClientFactory.CreateClient(weather); // 真实项目这里用 OpenWeatherMap 等地址当前写一个可替换的占位 var url $weather?city{Uri.EscapeDataString(city.Name)}unitsmetric; var response await client.GetFromJsonAsyncExternalWeatherDto(url, ct); var record new WeatherRecord { CityId city.Id, TemperatureC response!.TemperatureC, Humidity response.Humidity, Description response.Description, RetrievedAt DateTime.UtcNow }; _db.WeatherRecords.Add(record); await _db.SaveChangesAsync(ct); return record; }CreateClient(weather)使用Program.cs里配置的命名客户端命名客户端的优势是超时、重试策略可集中写不用每次 newHttpClient。Uri.EscapeDataString(city.Name)对中文城市名做 URL 编码直接拼city.Name在上海深圳这种城市没问题但换成含特殊字符的城市时就会出错。存入WeatherRecord后SaveChangesAsync这条记录就进入数据库下次查询直接读缓存。4.2 MVC 视图表单交给 View数据交给 fetchMVC 视图只承担两件事渲染城市下拉框和显示天气结果。完整 Index.cshtml 大致如下model WeatherFinderApp.Models.IndexViewModel div classcontainer mt-4 h1城市天气查询/h1 form idweatherForm classrow g-3 div classcol-auto label forcitySelect classform-label选择城市/label select idcitySelect classform-select namecityId foreach (var city in Model.Cities) { option valuecity.Idcity.Name/option } /select /div div classcol-auto align-self-end button typebutton idbtnSearch classbtn btn-primary查询/button /div /form div idweatherBox classmt-3 p classtext-muted还没有查询结果。/p /div /div下拉框由 Razor 服务端渲染选中的value是城市 ID不是城市名。namecityId保留是为了兼容以后用表单自身提交点查询走fetch时并不需要它。这样设计的原因是城市列表是稳定且低频变动的数据交给服务端渲染简单天气数据是高频变动数据交给 API 动态拉取更合理。4.3 前端使用 fetch 处理交互在wwwroot/js/site.js里加一个方法const citySelect document.getElementById(citySelect); const btnSearch document.getElementById(btnSearch); const weatherBox document.getElementById(weatherBox); async function loadWeather(cityId) { weatherBox.textContent 加载中...; try { const resp await fetch(/api/weather/ encodeURIComponent(cityId), { headers: { Accept: application/json } }); if (!resp.ok) { const error await resp.json().catch(() null); throw new Error(error?.title || HTTP ${resp.status}); } const data await resp.json(); weatherBox.innerHTML p${data.cityName}${data.description}/p p温度${data.temperatureC}°C湿度${data.humidity}%/p p classtext-muted更新时间${new Date(data.retrievedAt).toLocaleString()}/p; } catch (err) { weatherBox.innerHTML p classtext-danger查询失败${err.message}/p; } } btnSearch.addEventListener(click, () { const cityId citySelect.value; loadWeather(cityId); });cityId是数值正常不需要编码但encodeURIComponent是防御性写法以后改成城市名查询不会翻车。fetch(/api/weather/ cityId)走同源地址不涉及 CORS这比前后端分离方案少一个麻烦。resp.json().catch(() null)是防错误响应不是 JSON在WeatherController用ProblemDetails返回后这里读title字段作为用户提示。try/catch捕获的是网络层错误HTTP 非 2xx 不会进入异常所以要先查resp.ok。这样一套下来页面点击“查询”时不会整页刷新MVC 负责首屏Web API 负责数据EF Core 的缓存记录也会被更新。下一个要面对的问题就是这些接口在实际环境里会出现哪些错误以及如何处理。5. 不只在本地跑得通错误处理、日志与发布细节5.1 给 API 单独加一层错误兜底前面提到UseExceptionHandler(/Home/Error)会把 API 的 500 也变成 HTML 页面。一个简单的改进是在注册异常处理前插入自己的中间件app.Use(async (context, next) { try { await next(); } catch (Exception ex) { if (context.Request.Path.StartsWithSegments(/api)) { context.Response.StatusCode 500; await context.Response.WriteAsJsonAsync(new { title 服务器内部错误, detail app.Environment.IsDevelopment() ? ex.Message : null }); } else { throw; } } });这段中间件必须放在UseExceptionHandler前面并且只拦截路径以/api开头的请求非 API 请求继续抛给框架处理。开发环境下把ex.Message拼进响应生产环境置空避免泄露堆栈。前端那边自然会进到if (!resp.ok)分支提示用户稍后重试。5.2 常见状态码与检查顺序状态码出现位置检查顺序404/api/weather/{id}路由是否映射到MapControllers城市 ID 是否存在400外部天气 APIUri.EscapeDataString是否漏掉命名 HttpClient 的 BaseAddress 是否带协议500首次写缓存EF Core 迁移是否已执行database updateSQLite 连接串目录是否存在502fetch 网络层外部天气服务配额是否超限命名客户端的重试次数是否用完5.3 发布到 IIS 时的三处改动dotnet publish -c Release -o publish生成可直接部署的文件但 IIS 上要注意三个地方安装 .NET Core Hosting Bundleappsettings.json里的连接字符串改成生产路径在 web.config 的aspNetCore中确认stdoutLogEnabledtrue。如果用到外部天气服务把 API Key 放到环境变量里发布时设置名为ConnectionStrings__DefaultConnection和WeatherApi__Key的环境变量appsettings.json用builder.Configuration[WeatherApi:Key]读取即可。最后在正式环境执行一次dotnet ef database updateWeb 应用启动后先访问/Home/Index正常看到上海和深圳两个城市再访问/api/weather/1此时空的 WeatherRecord 表会让 API 调外部天气服务写回第一条记录第二次访问同一个端点响应时间明显缩短说明 EF Core 的外键约束和索引查询都在正常工作。本文还有配套的精品资源点击获取
返回列表