ASP.NET Core实战:MVC与Web API共存的城市天气应用
2026/9/15 17:29:17 网站建设 项目流程

简介:基于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],不返回 View
Models/City、WeatherRecord也可以放 DTO,避免把 EF 实体直接序列化
Data/DbContext 和迁移脚本控制器通过构造函数注入它
Views/Index.cshtml页面里只放表单和挂载点,数据交给 fetch

我一般会把这个目录讲给刚接触的人听:真正的学习成本不在目录名字,而在“同是 Controller 后缀,为什么 Home 返回 View,Weather 返回 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.AddDbContext<WeatherFinderDbContext>(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: "{controller=Home}/{action=Index}/{id?}"); // Web API 属性路由:/api/weather/1 app.MapControllers(); app.Run();

这段代码有两个地方不能省。AddControllersWithViews()把 MVC 与 API 的控制器一起注册进 DI 容器,若只写AddControllers(),Razor 视图找不到HomeController;只写AddControllersWithViews()则 API 路由缺失到只剩路由映射。另一个是路由端点:MapControllerRoute处理{controller=Home}/{action=Index}/{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是外键。很多人写到这里会问:为什么主表里不放List<WeatherRecord>?作为学习项目,不放集合导航属性可以少踩序列化成环的坑——当你在 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(DbContextOptions<WeatherFinderDbContext> options) : base(options) { } public DbSet<City> Cities => Set<City>(); public DbSet<WeatherRecord> WeatherRecords => Set<WeatherRecord>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<WeatherRecord>(entity => { entity.HasOne(w => w.City) .WithMany() .HasForeignKey(w => w.CityId) .OnDelete(DeleteBehavior.Cascade); entity.HasIndex(w => new { w.CityId, w.RetrievedAt }); }); // 种子城市,方便第一次启动时页面有下拉选项 modelBuilder.Entity<City>().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.jsonConnectionStrings:DefaultConnection,按上下文创建数据库文件。如果项目缺少Microsoft.EntityFrameworkCore.Design包,第二条命令会报错,需要先在 csproj 里加上它。

执行后可以在Data/Migrations看到三个文件:..._InitCityWeather.cs包含迁移操作,..._Designer.cs包含元数据,WeatherFinderDbContextModelSnapshot.cs是快照。这三个文件都要提交到代码库,不要加入.gitignore。每次模型变化就再dotnet ef migrations add Xxx,EF Core 会在快照基础上生成增量 SQL,不会重复创建已有表。

3.4 控制器里如何查询城市

EF Core 在控制器中的常见用法是过滤加分页。下面这个动作来自 MVC 的 Home 控制器,负责给页面提供城市下拉列表:

public class HomeController : Controller { private readonly WeatherFinderDbContext _db; public HomeController(WeatherFinderDbContext db) { _db = db; } public async Task<IActionResult> 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 Task<ActionResult<WeatherDto>> 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的简化实现可以依赖IHttpClientFactory

private async Task<WeatherRecord> FetchFromExternalServiceAsync(City city, CancellationToken ct) { var client = _httpClientFactory.CreateClient("weather"); // 真实项目这里用 OpenWeatherMap 等地址;当前写一个可替换的占位 var url = $"weather?city={Uri.EscapeDataString(city.Name)}&units=metric"; var response = await client.GetFromJsonAsync<ExternalWeatherDto>(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里配置的命名客户端;命名客户端的优势是超时、重试策略可集中写,不用每次 newHttpClientUri.EscapeDataString(city.Name)对中文城市名做 URL 编码,直接拼city.Name在上海深圳这种城市没问题,但换成含特殊字符的城市时就会出错。存入WeatherRecordSaveChangesAsync,这条记录就进入数据库,下次查询直接读缓存。

4.2 MVC 视图:表单交给 View,数据交给 fetch

MVC 视图只承担两件事:渲染城市下拉框和显示天气结果。完整 Index.cshtml 大致如下:

@model WeatherFinderApp.Models.IndexViewModel <div class="container mt-4"> <h1>城市天气查询</h1> <form id="weatherForm" class="row g-3"> <div class="col-auto"> <label for="citySelect" class="form-label">选择城市</label> <select id="citySelect" class="form-select" name="cityId"> @foreach (var city in Model.Cities) { <option value="@city.Id">@city.Name</option> } </select> </div> <div class="col-auto align-self-end"> <button type="button" id="btnSearch" class="btn btn-primary">查询</button> </div> </form> <div id="weatherBox" class="mt-3"> <p class="text-muted">还没有查询结果。</p> </div> </div>

下拉框由 Razor 服务端渲染,选中的value是城市 ID,不是城市名。name="cityId"保留是为了兼容以后用表单自身提交;点查询走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 class="text-muted">更新时间:${new Date(data.retrievedAt).toLocaleString()}</p>`; } catch (err) { weatherBox.innerHTML = `<p class="text-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,在WeatherControllerProblemDetails返回后,这里读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 update;SQLite 连接串目录是否存在
502fetch 网络层外部天气服务配额是否超限;命名客户端的重试次数是否用完

5.3 发布到 IIS 时的三处改动

dotnet publish -c Release -o publish生成可直接部署的文件,但 IIS 上要注意三个地方:安装 .NET Core Hosting Bundle;appsettings.json里的连接字符串改成生产路径;在 web.config 的aspNetCore中确认stdoutLogEnabled="true"。如果用到外部天气服务,把 API Key 放到环境变量里,发布时设置名为ConnectionStrings__DefaultConnectionWeatherApi__Key的环境变量,appsettings.jsonbuilder.Configuration["WeatherApi:Key"]读取即可。

最后在正式环境执行一次dotnet ef database update,Web 应用启动后先访问/Home/Index,正常看到上海和深圳两个城市,再访问/api/weather/1,此时空的 WeatherRecord 表会让 API 调外部天气服务写回第一条记录;第二次访问同一个端点,响应时间明显缩短,说明 EF Core 的外键约束和索引查询都在正常工作。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询