
简介这是一套基于ASP.NET MVC框架开发的微博类Web应用完整项目使用C#编写后端业务逻辑SQL Server存储用户、微博与好友关系数据并通过jQuery实现异步交互与动态页面效果适合正在学习ASP.NET Web开发、或需要参考课程设计/毕业设计项目的初中级开发者。压缩包共889个文件、约116.61MB文件类型丰富既有.cs源码、.cshtml视图、.js与.css前端资源也包含大量.gif图片、.dll依赖程序集及配置文件足以支撑项目直接运行与二次学习。项目覆盖MVC分层架构、路由与模型绑定、登录发布、好友管理、内容分页等关键模块同时配合SQL脚本和实体模型文件便于理解数据访问与业务实现全过程。已有646人查看学习适合通过阅读工程结构、调试运行和改动功能来系统掌握企业级Web应用的常见开发思路。1. 用 ASP.NET MVC 把微博的时间线做成产品而不是 Demo微博这种产品形态表面看是“发一条消息别人能看到”真正落到 ASP.NET MVC 里牵扯的是路由怎么设计才不让用户名和动作名打架、时间线查询怎么写才不拖垮数据库、AJAX 点赞之后 ViewData 还认不认账这类具体问题。本文不还原完整微博而是聚焦“用户—发布—时间线—关注”的最小闭环把 MVC 各层该有的样子讲清楚。适合刚用 ASP.NET MVC 做完增删改查、想往前再走一步的团队也适合准备用老 .NET Framework 栈接手二手项目的工程师——你会在这里看到官方教程没细说但线上必踩的几个点。2. 路由与 Controller 设计先把 /{username} 和 /Home/Index 的冲突解决掉2.1 默认路由为什么在微博场景下不够用ASP.NET MVC 5 项目模板自带的路由是{controller}/{action}/{id}它假设 URL 第一段是控制器名。但微博的核心页面是用户主页自然形态是weibo.com/zhangsan而不是weibo.com/User/Index/zhangsan。如果直接把默认路由改成正则匹配用户名所有/{something}的请求都会撞进用户主页控制器静态文件、/Home、/Account全部被劫持。常见做法是保留默认路由另加一条约束路由// App_Start/RouteConfig.cs public class RouteConfig { public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute({resource}.axd/{*pathInfo}); routes.IgnoreRoute(Content/{*relpath}); routes.IgnoreRoute(Scripts/{*relpath}); // 用户空间路由约束首段为合法用户名 routes.MapRoute( name: UserSpace, url: {username}, defaults: new { controller User, action Index }, constraints: new { username [a-zA-Z0-9_]{4,20} } ); routes.MapRoute( name: Default, url: {controller}/{action}/{id}, defaults: new { controller Home, action Index, id UrlParameter.Optional } ); } }这段代码的关键在于constraints用正则把用户名限制在 4-20 位字母数字下划线Home、Account这类控制器名同样满足该正则所以路由表顺序必须把Default放在UserSpace之前才能保证控制器优先。两种做法都有人用我更倾向把用户名正则放宽到允许中文但此时约束里要加^[\u4e00-\u9fa5a-zA-Z0-9_]{2,20}$同时注意 URL 编码问题——中文用户名在 URL 里会变成百分号编码路由匹配前 MVC 会先解码再比对实测没有问题只是日志里看起来费劲。2.2 微博核心动作的 Controller 设计微博的写操作有发微博、转发、评论、点赞、关注它们都指向同一个资源微博条目Status。按 REST 风格应该拆成多个控制器但 ASP.NET MVC 5 在System.Web.Mvc下没有现成的[Route]特性路由 API 版本除非你直接用RouteAttributeMVC 5 已内置。资源型动作可以这样组织public class StatusController : Controller { // GET /status/detail/123 [HttpGet] public ActionResult Detail(long id) { var vm _statusService.GetDetail(id, CurrentUserId); if (vm null) return HttpNotFound(); return View(vm); } // POST /status/publish [HttpPost] [ValidateAntiForgeryToken] public ActionResult Publish(PublishStatusViewModel model) { if (!ModelState.IsValid) return View(Compose, model); var statusId _statusService.Create(model.Content, CurrentUserId); return RedirectToAction(Detail, new { id statusId }); } // POST /status/delete [HttpPost] [ValidateAntiForgeryToken] public ActionResult Delete(long id) { var ok _statusService.Delete(id, CurrentUserId); if (!ok) return Json(new { success false, message 无权限或已删除 }); return Json(new { success true }); } }这里能看到三个微博场景下的必要设计。第一写操作全部走[HttpPost]且带[ValidateAntiForgeryToken]防止跨站请求伪造这也是热词里提到 ASP.NET 加密__ViewState反序列化时很多人会混淆的点——MVC 不用 ViewState防伪令牌是独立的__RequestVerificationToken和 WebForms 的 ViewState 是两套机制。第二删除用 POST JSON 返回而不是 GET 带?id直接删因为搜索引擎爬虫和预加载插件都可能导致误删。第三Service 层返回null表示资源不存在时控制器要转HttpNotFound()不能往下走 View 渲染。2.3 页面跳转用 PRG 模式还是 AJAX发微博成功后传统做法是RedirectToAction走 PRGPost-Redirect-Get这样刷新页面不会重复提交。但在微博时间线上用户发完微博期望的是时间线立刻出现新内容而不是跳到详情页。两种策略不冲突第一次发微博走 PRG 跳到Detail在详情页点“转发”时才用 AJAX。[HttpPost] [ValidateAntiForgeryToken] public ActionResult Repost(long sourceId, string comment) { var newId _statusService.Repost(sourceId, CurrentUserId, comment); return Json(new { success true, newStatusId newId }); }AJAX 请求返回JsonResult在 View 层用 jQuery 把返回的新微博 ID 拼到时间线顶部。注意ValidateAntiForgeryToken对 AJAX 同样生效jQuery 发送时要带上 token// 在 _Layout.cshtml 里输出 token 到 meta // meta nameanti-forgery-token contentGetAntiForgeryToken() / $.ajax({ url: /status/repost, method: POST, data: { sourceId: 123, comment: 转发理由, __RequestVerificationToken: $(meta[nameanti-forgery-token]).attr(content) } });GetAntiForgeryToken()是自定义的辅助方法内部调用AntiForgery.GetTokens()生成一对 token一个写 Cookie 一个写 meta。这是老 MVC 项目常见的做法因为默认的Html.AntiForgeryToken()输出的是表单里的隐藏域AJAX 拿不到。3. Model 与 EF 数据访问时间线查询的 SQL 你能背出来吗3.1 微博时间线的标准表结构微博最小模型需要四张表用户表、微博表、关注关系表、点赞表。EF 6 用 Code First 定义时最容易踩的坑是自引用导航属性和多对多关系。下面给出可直接用的实体定义public class User { [Key] public long Id { get; set; } [Index(IsUnique true)] [MaxLength(20)] public string UserName { get; set; } public string DisplayName { get; set; } public DateTime CreatedAt { get; set; } // 我关注的人 public virtual ICollectionFollowing Followings { get; set; } // 关注我的人 public virtual ICollectionFollowing Followers { get; set; } } public class Status { [Key] public long Id { get; set; } [Required] [MaxLength(2000)] public string Content { get; set; } public long AuthorId { get; set; } public virtual User Author { get; set; } public DateTime CreatedAt { get; set; } public int RepostCount { get; set; } public int LikeCount { get; set; } // 软删除标志 public bool IsDeleted { get; set; } } public class Following { [Key] public long Id { get; set; } // 关注者 public long FollowerId { get; set; } public virtual User Follower { get; set; } // 被关注者 public long FolloweeId { get; set; } public virtual User Followee { get; set; } public DateTime CreatedAt { get; set; } }Following是典型的自关联多对多桥表不用ICollectionUser直接多对多是因为我们还要记录关注时间而且将来可能要加“特别关注”分组字段。EF 6 的 Fluent API 要把两对关系分别配置modelBuilder.EntityFollowing() .HasRequired(f f.Follower) .WithMany(u u.Followings) .HasForeignKey(f f.FollowerId) .WillCascadeOnDelete(false); modelBuilder.EntityFollowing() .HasRequired(f f.Followee) .WithMany(u u.Followers) .HasForeignKey(f f.FolloweeId) .WillCascadeOnDelete(false);把级联删除关掉是必须的否则 SQL Server 会报多个级联路径错误。用户注销时不物理删除用标志位软删否则他发过的微博全部要跟着处理数据一致性非常难维护。3.2 时间线查询和一个 MySQL 换 SQL Server 才知道的坑首页时间线的需求是拉取我关注的所有人的最新微博按时间倒序分页。EF 里最直接的写法是var timeline db.Statuses .Where(s !s.IsDeleted) .Where(s db.Followings .Where(f f.FollowerId currentUserId) .Select(f f.FolloweeId) .Contains(s.AuthorId)) .OrderByDescending(s s.CreatedAt) .ThenByDescending(s s.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToList();这段代码的问题不在逻辑而在Contains生成的 SQL。EF 6 会把子查询翻译成WHERE s.AuthorId IN (SELECT FolloweeId FROM Followings WHERE FollowerId p0)当关注人数很多时SQL Server 的IN子查询可能触发参数嗅探问题第一次传入的关注人数会决定执行计划是否走索引。常见的替代方案是用EXISTSvar timeline db.Statuses .Where(s !s.IsDeleted) .Where(s db.Followings .Any(f f.FollowerId currentUserId f.FolloweeId s.AuthorId)) .OrderByDescending(s s.CreatedAt) .ThenByDescending(s s.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToList();用 SQL Server Profiler 看执行计划EXISTS版本通常比IN版本更稳定。另一个坑是Skip/Take在数据量大的时候性能下降微博这种产品到百万级微博后标准方案是改成键集分页——用上一页最后一条微博的CreatedAt和Id作为游标var timeline db.Statuses .Where(s !s.IsDeleted) .Where(s db.Followings.Any(f f.FollowerId currentUserId f.FolloweeId s.AuthorId)) .Where(s s.CreatedAt lastTimestamp || (s.CreatedAt lastTimestamp s.Id lastId)) .OrderByDescending(s s.CreatedAt) .ThenByDescending(s s.Id) .Take(pageSize) .ToList();键集分页的代价是前端不再能直接跳转到第 N 页只能“加载更多”但换来的优势是无论翻多深查询都走(CreatedAt, Id)复合索引延迟恒定。实现时在CreatedAt上建非聚集索引即可。3.3 软删除下的全局过滤器IsDeleted标志如果每个查询都手动加条件漏一处就会出现“已删微博还挂在时间线上”的线上事故。EF 6 提供了HasQueryFilterEntity Framework Core 才有EF6 老项目没有这个特性。常见做法是用拦截器或者封装 Repositorypublic class StatusRepository { private readonly WeiboDbContext _db; public IQueryableStatus Query() { return _db.Statuses.Where(s !s.IsDeleted); } }所有查询入口都走这个Repository不直接暴露DbSetStatus。团队规矩是“谁绕过 Repository 谁负责修复生产数据”听着粗暴但在小型团队里比抽象一个拦截器更有效。如果你用的不是 EF6 而是 EF Core 5直接配置modelBuilder.EntityStatus().HasQueryFilter(s !s.IsDeleted)连同 Include 出来的导航也会自动套上过滤省掉很多重复代码。4. View 与前端交互Razor 语法做微博信息流ViewData 别滥用4.1 强类型视图与 DisplayTemplate微博信息流的每一条微博有内容、作者头像、发布时间、转发数、点赞数如果用ViewBag传一个ListStatus然后在视图里foreach裸奔页面一复杂就失控。正确打开方式是给时间线建专用 ViewModelpublic class TimelineItemViewModel { public long StatusId { get; set; } public string Content { get; set; } public DateTime CreatedAt { get; set; } public string AuthorName { get; set; } public string AuthorAvatarUrl { get; set; } public int RepostCount { get; set; } public int LikeCount { get; set; } public bool LikedByCurrentUser { get; set; } public bool IsAuthor { get; set; } }LikedByCurrentUser和IsAuthor是 UI 状态不该塞进 Domain Model。在 Service 层把Status转换成TimelineItemViewModel用 AutoMapper 可以少写样板代码但要注意 EF 的延迟加载——如果Status.Author是虚属性且 DbContext 已释放AutoMapper 映射时会抛ObjectDisposedException。所以 Service 里查询就写好.Include(s s.Author)或者干脆投影成匿名类型再映射。Razor 视图建议用 DisplayTemplate 渲染单条微博而不是在foreach里写一大坨 HTMLmodel IEnumerableASPNET_Weibo.Models.ViewModels.TimelineItemViewModel div idtimeline foreach (var item in Model) { Html.DisplayFor(m item, TimelineItem) } /divViews/Shared/DisplayTemplates/TimelineItem.cshtml这个名字必须和模板名一致MVC 才能按约定找到。4.2 AJAX 点赞和防伪令牌的前端配合点赞是微博里最高频的写操作。按钮初始状态根据LikedByCurrentUser渲染点击后走 AJAX。常见错误是只传一个statusId后端不校验用户是否已点赞就INSERT导致重复点赞数据。正确做法是后端用if (!db.Likes.Any(l l.StatusId id l.UserId uid))做幂等判断返回的 JSON 里带上最新点赞数让前端更新 UI[HttpPost] [ValidateAntiForgeryToken] public JsonResult Like(long statusId) { var uid CurrentUserId; var likeService new LikeService(db); var result likeService.Toggle(statusId, uid); return Json(new { success true, liked result.Liked, likeCount result.LikeCount }); }前端拿到likeCount后更新.like-count的文本不要相信前端自己1因为其他人可能也在点赞。另外如果你是 XSS 的受害者——用户昵称里带script——那Html.DisplayFor默认会 HTML 编码安全但如果你图省事用Html.Raw(item.Content)渲染微博正文必须自己在服务器端做净化白名单允许a,span,img标签。单纯替换和不够因为onerror属性还能执行 JS。ASP.NET MVC 5 项目里最常见的安全库是 AntiXSSMicrosoft AntiXSS已退役或者自己用HtmlAgilityPack删掉非白名单属性和事件句柄。4.3 ViewBag 与 ViewData 的边界很多从 WebForms 转过来的开发者习惯用ViewBag.Title xxx传页面标题这在微博这种多区块页面里埋坑子页面里ViewBag的键冲突不会报错但值会被覆盖而且字符串拼错键名时静默返回 null。我在微博项目里的规矩是布局页需要的少量参数走ViewBag如Title、CanonicalUrl页面主体数据一律强类型 ViewModel。这样 View 的编译期类型检查在改字段名时能立刻报错而不是线上白屏。5. 身份认证与授权基于 Cookie 的登录态在微博里的正确姿势5.1 FormsAuthentication 改造为 JSON 友好的登录接口ASP.NET MVC 5 自带模板用 ASP.NET Identity 且默认走UseCookieAuthentication登录页面是服务器渲染的表单。微博场景里登录通常是从站外跳转或者移动端 AJAX 提交标准做法是保留登录页表单但额外提供 JSON 登录端点。核心是认证票据的签发时机[HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public async TaskActionResult Login(LoginViewModel model, string returnUrl) { if (!ModelState.IsValid) return View(model); var signInManager HttpContext.GetOwinContext().GetApplicationSignInManager(); var result await signInManager.PasswordSignInAsync(model.UserName, model.Password, model.RememberMe, false); switch (result) { case SignInStatus.Success: return RedirectToLocal(returnUrl); case SignInStatus.LockedOut: ModelState.AddModelError(, 账号已锁定); return View(model); default: ModelState.AddModelError(, 用户名或密码错误); return View(model); } }PasswordSignInAsync内部会做两件事调用UserManager.VerifyPasswordAsync验证凭据再调用AuthenticationManager.SignIn签发 Cookie。有一个安全细节是shouldLockout参数——如果你不想给未验证邮箱的用户加锁可以传false但那样暴力破解的防护就没了。微博这种公开产品必须开锁连续 5 次失败锁定 15 分钟ApplicationUserManager里默认的MaxFailedAccessAttemptsBeforeLockout 5够用。5.2 Controller 里取当前用户不要直接啃 Cookie登录变 AJAX 后最容易出现的误操作是开发者为了在前端显示当前用户名把用户 ID 塞进了ViewBag或 JavaScript 变量。这是信息泄露的起点别人看网页源码就知道你内部 UserId 的自增规律配合GET /status/detail/1可以遍历出早期所有微博。正确做法是在服务端用User.Identity.GetUserId()拿当前用户仅仅在需要 AJAX 判断“我是否已点赞”时用Url.Action生成一个带认证的端点由 Controller 内部判断。public long CurrentUserId { get { var uid User.Identity.GetUserId(); return string.IsNullOrEmpty(uid) ? 0 : long.Parse(uid); } }注意GetUserId()是Microsoft.AspNet.Identity的扩展方法默认返回的是字符串形式的 GUID。如果表里UserId用的是自增 long需要重写IUserStore或者在注册时把 GUID 和自增 ID 做映射表。老项目常见做法是自定义UserManagerTUser, longTUser的主键类型传 long这里牵扯到 Identity 的泛型参数重载不建议新手直接改简单方案是就保留 GUID 主键展示名用UserName避免在 ID 生成上浪费时间。5.3 Authorize 特性的两个微博专属坑[Authorize]默认只判断是否登录不区分角色。微博场景里“仅自己可见”的微博需要判断资源归属不是简单加[Authorize]就行public ActionResult MyPrivateStatuses() { var uid CurrentUserId; var statuses db.Statuses .Where(s s.AuthorId uid s.Visibility Visibility.Private) .OrderByDescending(s s.CreatedAt) .ToList(); return View(statuses); }数据库查询里直接用AuthorId uid而不是从User.Identity再查一次用户表性能上没差别但后者容易出现主键类型转换异常。另一个坑是 AJAX 请求在未登录时会返回 302 重定向到登录页前端$.ajax拿到的是登录页的 HTML 而不是预期 JSON回调里解析data.success必然 undefined。所以 AJAX 回调开头要判断data类型或 HTTP 状态码$.ajax({ url: /status/like, method: POST, data: { statusId: id } }) .done(function (data) { if (typeof data object data.success ! undefined) { // 正常 JSON } else { location.href /account/login?returnUrl encodeURIComponent(window.location.pathname); } }) .fail(function (xhr) { if (xhr.status 401) { location.href /account/login; } });后端配合方面最优雅的方案是在Application_EndRequest或自定义AuthorizeAttribute里判断请求头X-Requested-With: XMLHttpRequest如果是 AJAX 未授权则返回 401 状态码而不是 302public class AjaxAuthorizeAttribute : AuthorizeAttribute { protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext) { var req filterContext.HttpContext.Request; if (req.IsAjaxRequest()) { filterContext.Result new HttpStatusCodeResult(401); } else { base.HandleUnauthorizedRequest(filterContext); } } }顺手把AjaxAuthorizeAttribute放到点赞、转发、删除这些动作上前端逻辑就干净了。6. 性能调优与部署微博页面 2 秒开不了的排查顺序6.1 输出缓存与 Donut Caching 的现实选择微博首页的公共时间线未登录可看的推荐流是典型的读多写少且内容对所有访客一致适合输出缓存。ASP.NET MVC 5 里最直接的是[OutputCache][OutputCache(Duration 60, VaryByParam page, Location OutputCacheLocation.Server)] public ActionResult PublicTimeline(int page 1) { var statuses _statusService.GetPublicTimeline(page); return View(statuses); }VaryByParam page表示不同页码分别缓存Location.Server把缓存放在服务器内存不涉及客户端缓存防止用户看到过期数据。真正的问题出在登录用户的时间线上——每个用户关注的人不同缓存无法共享。标准做法是做“用户维度的混合缓存”用缓存键user_{userId}_timeline_{page}存ListTimelineItemViewModel有效期 30 秒发布新微博时删除自己的缓存键。这里的坑是当好友微博变化时我看到的首页时间线不会立刻包含它但微博产品普遍接受 30 秒以内的延迟。实现时不要用HttpRuntime.Cache直接在 Controller 里 get/set因为服务器多进程部署时缓存不共享。如果团队没上 Redis就在单机场景里用MemoryCache.Default加锁public ListTimelineItemViewModel GetTimeline(long userId, int page) { var key $timeline_{userId}_{page}; var cached MemoryCache.Default.Get(key) as ListTimelineItemViewModel; if (cached ! null) return cached; lock (_lockObj) { cached MemoryCache.Default.Get(key) as ListTimelineItemViewModel; if (cached ! null) return cached; cached LoadTimelineFromDb(userId, page); MemoryCache.Default.Set(key, cached, DateTimeOffset.Now.AddSeconds(30)); return cached; } }双检查锁避免缓存击穿——同一时刻大量并发请求发现缓存为空时只有一个线程查库。微博项目做到后面Redis 是必选项但用 MemoryCache 顶住初期的访问量完全可行。6.2 图片与静态资源IIS 静态压缩比你想的更重要微博页面里的头像、配图占页面体积的大头。ASP.NET MVC 本身不处理图片缩放常见方案是上传时用System.Drawing生成多尺寸缩略图但这玩意儿在 Windows Server 上要装 GDI 且容易内存泄漏。老项目更务实的做法是原图上传到独立文件服务器或云存储页面按需请求不同尺寸CDN 负责边缘缓存。IIS 层面开两个开关动静分离后收益明显system.webServer urlCompression doStaticCompressiontrue doDynamicCompressionfalse / /system.webServer静态压缩只压缩 js/css/图片以外的文本资源图片本身不再压缩JPG/PNG 压缩率低还费 CPU。动态压缩关掉是因为动态页面无法预知 Content-Type 且每次请求都要压缩CPU 开销太高。CSS/JS 文件建议在部署时用打包工具处理成带哈希指纹的文件名利用浏览器强缓存bundles.Add(new ScriptBundle(~/bundles/weibo) .Include(~/Scripts/jquery-3.6.0.js, ~/Scripts/weibo.js));默认的BundleTable.EnableOptimizations true开启后自动压缩和合并文件名带哈希内容变化时 URL 跟着变不会出现“改了 JS 但用户浏览器还在用老文件”的尴尬。6.3 用 MiniProfiler 定位慢请求没有性能分析就调优等于瞎猜。老 ASP.NET MVC 项目里最轻量的分析工具是 MiniProfilerNuGet 包名MiniProfiler.Mvc5注册步骤很少// Global.asax Application_Start protected void Application_Start() { AreaRegistration.RegisterAllAreas(); FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); BundleConfig.RegisterBundles(BundleTable.Bundles); var profiler MiniProfiler.Default; profiler.Start(); }然后在Application_BeginRequest和Application_EndRequest里包一层protected void Application_BeginRequest(object sender, EventArgs e) { MiniProfiler.Start(); } protected void Application_EndRequest(object sender, EventArgs e) { MiniProfiler.Stop(); }页面右下角会出现一个速度计图标点开能看到每个 EF 查询花了多少毫秒。重点关注那些耗时超过 200ms 的 SQL——微博时间线如果 500ms 还没回来按经验要么没建复合索引要么是延迟加载导致的 N1 查询用 MiniProfiler 一眼就能看出来。线上部署时记得用MiniProfiler的授权控制只对管理员 IP 显示否则等于把 SQL 语句完全暴露给访客。本文还有配套的精品资源点击获取