ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MVC从未过时:从Spring Boot到Vue的前后端架构演化与实战解析

MVC从未过时:从Spring Boot到Vue的前后端架构演化与实战解析 1. MVC为什么被骂过气却依然统治着我们的代码库先聊一个我经常在面试里碰到的现象很多候选人一听到MVC第一反应是这不是大学课本里的老古董吗但真让他们说说现在项目怎么组织的十有八九还是Controller、Service、Model那套。嘴上说着过时手底下全是MVC。这其实不矛盾。MVC这个概念确实有四十多年历史了但它解决的是一个永恒的软件工程问题怎么把复杂的业务逻辑塞进一个能让人类大脑理解的结构里。只要软件还在变复杂这个问题就不会消失MVC的底层思想也就不会过时。那些声称不用MVC的框架比如React、Vue仔细扒开看里面全是MVC精神的延伸。这篇文章我想做一件比较完整的事从后端经典框架ASP.NET Core、Spring Boot、Gin这些一路讲到前端现代化架构MVVM、组件化、跨端框架再到实际开发中绕不开的跨域、重复提交、版本刷新、大文件上传这些高频实战问题。尽量把我这些年踩过的坑、总结过的规律一次性讲透适合正在学后端想往前端看一眼的同学也适合前端工程师回头补一补后端那套设计逻辑。先说一个我在实际项目里反复验证过的结论MVC的三大核心价值从1980年代到今天一个字都没变过——职责分离、可测试性、并行开发。变的只是每一层长什么样、怎么通信。在ASP.NET Core里Controller是个普通类靠特性标注路由。在Gin里Controller变成了HandlerFunc本质是一个函数。在Vue里Controller被拆成了Router Pinia Store Composable。在Flutter里Controller干脆叫Controller视图层叫WidgetModel还是Model。名字千变万化套路始终如一让数据长什么样、数据怎么处理、数据怎么展示这三件事互不干扰。这就是为什么你在2024年接一个RuoYi框架的项目打开源码一看Controller、Service、Mapper三层清清楚楚跟教科书上一模一样。不是RuoYi老土是这套东西确实好用。下面我从头拆一遍把每个阶段的实现逻辑和选型理由讲清楚。2. 后端经典框架到底在MVC里做了什么从Spring MVC到Gin、FastAPI逐一拆解2.1 Controller不是控制器它是翻译官很多人刚学Spring MVC的时候以为Controller层就是写业务逻辑的地方这其实是最常见的误解。Controller在MVC里的真正职责只有两件半事拿到HTTP请求 → 把参数翻译成程序能用的东西 → 把Models的处理结果翻译回HTTP响应。剩下那半件事是校验参数格式。我见过太多代码把Service层的逻辑全堆在Controller里接口几百行最后谁也维护不动。判断Controller写得健不健康有一个很朴素的标准如果Controller里出现了超过5行的业务逻辑就应该拆到Service层去。Spring Boot 3的Controller典型写法基本长这样RestController RequestMapping(/api/users) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } GetMapping(/{id}) public ResultUserVO getUser(PathVariable Long id) { UserVO vo userService.getUserDetail(id); return Result.success(vo); } }注意这里的三件事通过注解声明路由、通过参数注解取数据、通过统一返回体包结果。Controller永远不会直接操作数据库永远不会写业务判断它只做编排。这种哑Controller写法在ASP.NET Core里也一模一样只是注解换成了[HttpGet]、[Route]返回对象换成IActionResult。2.2 Spring MVC、MyBatis和三层架构这对黄金组合真正让后端MVC落地的是Spring Boot MyBatis这套组合。很多Java后端项目从RuoYi框架起步打开代码你会发现它的分层极其清晰Controller接收请求Service写业务流程MapperDAO管数据库读写Model/VO做数据传输。这套结构的核心价值在于让每一层都能独立测试、独立替换。举个例子MyBatis的Mapper接口和XML文件分离让SQL可以单独维护。这解决了什么解决了Java代码里拼SQL字符串这种远古灾难。select idselectUserList resultTypeUserVO SELECT u.id, u.username, u.avatar, u.status FROM sys_user u WHERE u.del_flag 0 if testusername ! null and username ! AND u.username LIKE CONCAT(%, #{username}, %) /if ORDER BY u.create_time DESC /selectService层的典型写法是事务边界的体现。Spring的Transactional注解声明式管理事务一个方法就是一组原子操作Service public class UserServiceImpl implements UserService { Transactional(rollbackFor Exception.class) public void updateUserStatus(Long userId, Integer status) { UserEntity entity userMapper.selectById(userId); if (entity null) { throw new RuntimeException(用户不存在); } userMapper.updateStatus(userId, status); // 可能还有清除缓存、记录日志等操作 } }这套东西为什么能统治Java后端十几年因为它在灵活性和规范性之间找到了一个能长期维持平衡的点。你的团队可以是两个人也可以是两百人按Controller→Service→Mapper这个顺序写代码基本不会出大乱子。2.3 Gin脚手架MVCGo语言世界里怎么玩分层Go社区对MVC的态度很有意思——框架层面不太爱谈MVC但实际项目的目录结构比Java还像MVC。Gin框架的脚手架通常长这样project/ ├── main.go ├── routers/ │ └── router.go ├── controllers/ │ └── user_controller.go ├── services/ │ └── user_service.go ├── models/ │ └── user.go ├── middlewares/ │ └── auth.go └── config/ └── config.goGin里一个典型的Controller是通过函数实现的而不是结构体func GetUser(c *gin.Context) { idStr : c.Param(id) id, err : strconv.ParseInt(idStr, 10, 64) if err ! nil { c.JSON(http.StatusBadRequest, gin.H{error: 无效的用户ID}) return } user, err : userService.GetUserByID(id) if err ! nil { c.JSON(http.StatusNotFound, gin.H{error: err.Error()}) return } c.JSON(http.StatusOK, user) }这里要提一个很多Go新手会忽略的细节Handler之间不能直接传递错误必须靠返回值或者中间件统一处理。Gin提供了c.AbortWithStatusJSON()这种机制让校验失败的请求能及时打断这是Go世界MVC和Java MVC一个比较明显的区别。为什么Gin的项目管理体验比很多Java项目好核心原因是Go的包管理机制在编译期就强制了依赖方向——controllers依赖servicesservices依赖models这个依赖箭头画反了代码就编译不过。这其实就是在语言层面替你把MVC的规范执行了。2.4 FastAPI用类型系统给传统MVC注入现代血液FastAPI是近几年我唯一愿意认真推荐给新手的Python后端框架因为它把写接口这件事变成了声明接口。传统Python后端Django、Flask的MVC更多靠约定而FastAPI靠类型注解把Model和Controller的边界做了硬约束。这里最漂亮的机制是BaseModel和依赖注入。请求体、响应体全部用类型声明一旦类型对不上框架在入口就拦截了根本到不了你的业务代码from pydantic import BaseModel, EmailStr from fastapi import Depends class UserCreate(BaseModel): username: str email: EmailStr password: str class UserController: def create_user(self, payload: UserCreate, dbDepends(get_db)): user user_service.create_user(payload) return {id: user.id, username: user.username}FastAPI让我印象最深的一个设计是依赖注入体系。数据库会话、当前登录用户、权限校验全部通过Depends()声明而不是在函数里手动获取。这让Controller的每个方法都极其干净——它只需要声明我需要什么至于这些东西怎么构造、怎么释放全由框架处理。这算什么这是MVC的进化版Controller从怎么写职责进化为怎么声明职责。你不管底层实现只管接口契约。2.5 路由设计里的特殊场景给某个方法指定专属路由后端开发中关于MVC的一个高频搜索词是mvc 某个方法特殊路由指定这说明路由再怎么规范也总有特例。我处理这类问题的经验是三条原则常规操作走默认路由约定REST风格规范路由/users对应列表/users/{id}对应详情。跨资源的操作走子路由比如/users/{id}/orders表示查某个用户的订单列表。特殊情况才指定路由但必须让路由名能描述业务意图不能叫/do、/handle这种。ASP.NET Core的用法如下[Route(api/[controller])] public class UserController : ControllerBase { [HttpGet(recent/{count})] public IActionResult GetRecentUsers(int count) { var users _userService.GetRecentUsers(count); return Ok(users); } }Spring Boot里加注解GetMapping(/recent/{count})是一样的道理。我的实操建议是特殊路由的数量应该严格控制在一眼能数完的级别。如果项目中出现了超过10个自定义路由大概率是Model设计出了问题而不是路由的问题。3. 前端现代化的演进路径MVC如何变成MVVM再变成组件化3.1 从jQuery直接操作DOM说起要理解前端为什么走向MVVM得先回到那个前端没有架构的年代。2010年之前前端的主流方案是jQuery直接操作DOM// 典型的jQuery写法手动监听、手动更新DOM $(#btn-submit).click(function() { var username $(#username).val(); $.post(/api/login, { username }, function(data) { if (data.success) { $(#result).text(登录成功).css(color, green); } else { $(#result).text(登录失败).css(color, red); } }); });这个写法的核心问题在于数据和界面之间没有同步机制全靠程序员手动维护。当页面交互变多之后哪里改了数据、哪里没同步DOM就成了一个纯靠记性的事。产品经理说这里加一个字段你得找遍所有可能引用了它的JS代码。这种状态维护的复杂度随着页面变复杂是指数级增长这就是典型的意大利面条代码。3.2 Vue和React如何实现自动同步MVVM核心机制拆解后来前端框架给出的解法是MVVM——ViewModel层负责把Model和View这两个世界对接起来。你只需要声明数据长这样框架自动帮你完成数据变了界面跟着变这件事。以Vue 2/3为例template div p用户名{{ username }}/p input v-modelusername / button clicksubmit提交/button /div /template script setup import { ref } from vue const username ref() async function submit() { const res await fetch(/api/users, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ username: username.value }) }) // 业务处理... } /script关键点在于那个{{ username }}和v-model。你不再需要写把输入框的值取出来再塞到某个DOM节点里Vue的响应式系统在数据变化时自动完成视图更新。这不仅仅是省代码量的问题——它让**界面状态和业务数据这两条时间线彻底解耦了**。React的实现路径不一样但目标一致通过setState触发重新渲染通过Virtual DOM对比最小化真实DOM的修改。注意React的核心理念是UI是数据的纯函数——view f(state)。这个理念比Vue更激进Vue允许你在模板里写少量逻辑而React严格禁止在render里做任何有副作用的事。两者殊途同归都是为了同一件事让界面彻底依赖数据而不是被命令操作牵着走。3.3 前后端分离之后Model到底去哪儿了提到前后端分离很多转前端的同学会犯迷糊MVC里的Model层在Vue和React项目里到底存在不存在我的答案是存在但换了个马甲。在后端MVC里Model对应数据库表结构的映射而在前端MVVM里Model变成了接口返回的数据结构 前端定义的TypeScript类型 Pinia/Redux里的状态。举个例子。后端返回的用户对象{ id: 1, username: 张三, avatarUrl: https://cdn.example.com/avatar.jpg, lastLoginTime: 2024-06-01T10:00:00Z }前端的Model就是对应的TypeScript接口interface UserModel { id: number username: string avatarUrl: string lastLoginTime: string }以及Pinia里的Storeexport const useUserStore defineStore(user, { state: () ({ userInfo: null as UserModel | null, token: }), actions: { async fetchUser(id: number) { const { data } await api.getUser(id) this.userInfo data } } })所以前后端分离项目的完整MVC链路其实是前端Vue组件View→ Pinia/Actions方法ViewModel/Controller→ API请求 → 后端Controller → 后端Service → 数据库。这里最需要团队达成共识的地方是接口返回的数据结构就是前后端共同认定的前端Model。所以设计接口时返回给前端的数据尽量不要直接是数据库表结构而是经过语义化处理之后的VOView Object。后端有引擎、有前端有视图的团队沟通接口的时间几乎都花在你给的字段跟我想要的字段对不上这件事上。3.4 Flutter、uni-app跨端框架里的MVC变体跨端框架的前端架构是很多人会忽略的一个面。我聊一下Flutter一切皆WidgetController是真正有名字的类TextEditingController、ScrollControllerModel靠ChangeNotifier做状态管理。它的MVC其实更接近MVC Provider的组合——Widget是ViewNotifier是Controller数据类是Model。uni-app本质上是Vue的跨端封装所以写法跟Vue3很像MVC逻辑就是MVVM逻辑。用户登录、支付、列表加载都是标准的数据 → 响应式 → 视图更新。跨端框架的MVC难点不在MVC本身上而在平台差异的适配。比如同一个input组件在小程序里可能有自己的聚焦/失焦行为在Web端行为完全正常。这时候Controller层往往得做一层平台判断这是跨端项目里MVC边界最容易被打破的地方。4. 前后端协作逼出来的实战问题《按钮重复提交》《跨域》《强制刷新》《大文件上传》逐一解决4.1 后端跨域为什么浏览器不让你调自己部署的接口前后端分离项目上线第一个问题永远是跨域。这个问题的根源不是后端不开放而是浏览器强制安全策略——同源策略。同源指的是协议、域名、端口三者都相同任何一个不同就是跨域。解决跨域的主流方案是CORS跨域资源共享这是一个需要在后端设置的HTTP头。Spring Boot的写法Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(https://admin.example.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }Gin的中间件写法r.Use(cors.New(cors.Config{ AllowOrigins: []string{https://admin.example.com}, AllowMethods: []string{GET, POST, PUT, DELETE, OPTIONS}, AllowHeaders: []string{*}, AllowCredentials: true, }))我的实操心得就一条allowedOrigins一定不要配*配合allowCredentials: true时浏览器会直接报错。你必须在配置里写明白是给哪个域名开放的。虽然开发阶段可以用*图省事但上线之前一定要改成白名单这既是为了安全也是让前后端联调时问题更好排查——因为跨域报错信息非常明确一眼就能看出是配置问题还是代码问题。4.2 按钮重复提交的双保险前端防手滑后端防并发前后端对于按钮重复提交校验方法这个话题几乎每个做管理系统的团队都踩过。场景很常见用户点了一下保存没反应又点了一下结果数据库里写了两条重复数据。我的处理原则是前端做体验层防护后端做数据层兜底。两者缺一不可。前端最简单有效的方案是提交锁template button :disabledloading clicksubmit {{ loading ? 提交中... : 确认提交 }} /button /template script setup import { ref } from vue const loading ref(false) async function submit() { if (loading.value) return loading.value true try { // 提交逻辑 } finally { loading.value false } } /script这个方法屏蔽了90%的误操作但不能防用户按了两次回车这类事件也不能防用户用脚本请求接口。真正的安全底线只能在后端幂等键方案前端每次提交带着一个requestIdUUID后端在表里记录这个ID处理过了就直接返回原结果。数据库唯一索引对于用户名、手机号、订单号这类业务唯一字段直接建唯一索引重复插入会抛异常由全局异常处理器兜底返回友好提示。ALTER TABLE order ADD UNIQUE KEY uk_order_no (order_no);后端兜底做好的话前端防手滑做好的话这块就不会有线上事故。我见过的绝大多数重复数据事故都是只做了前端校验、后端毫无防备或者后端只有Redis锁但没处理分布式场景。这里再提一句如果项目是集群部署本地锁synchronized、Lock没用必须上Redis分布式锁或者数据库唯一索引这种跨进程方案。4.3 前端强制刷新的版本方案千万别靠用户自己清缓存通过版本号的变更让前端强制刷新页面这个需求本质是前端代码更新后如何确保用户加载到的是新版本而不是缓存的旧版本。这个问题经典的解药是构建工具webpack/vite的文件指纹fingerprint机制app.3f9a2c7b.js app.3f9a2c7b.js.map style.css?v20240611文件内容变了文件名就变用户请求的地址就变浏览器缓存就自动失效。这属于自动版本号变更方案最省心。但还有一个更常见的痛点SPA应用里用户一直开着旧页面没有重新加载HTML文档怎么让他强制更新。我的做法是维护一个version.json或通过接口返回当前版本号前端启动时对比let currentVersion 1.2.0 async function checkVersion() { const res await fetch(/version.json, { cache: no-store }) const data await res.json() if (data.version ! currentVersion) { // 弹窗提醒用户刷新或者直接location.reload() ElMessageBox.confirm(系统已更新到新版本点击确定立即刷新, 提示, { confirmButtonText: 立即刷新, type: warning }).then(() { window.location.reload(true) }) } }这里要特别提醒window.location.reload()不一定能绕过缓存因为它是重新加载当前文档如果HTML本身被缓存了就又回去。所以更稳妥的办法是location.href location.origin /index.html?v Date.now()。另外把index.html的Cache-Control设置为no-cache是让版本更新生效的前提。4.4 验证码输入框、Worker上传还有ECharts闪烁这种小问题搜了一下才发现大家搜的都是一些具体的痛点我扩展开聊一下这些真的都是实战高频验证码输入框的难点在于用户可以粘贴和需要逐位校验之间的矛盾。我的实现思路是一个隐藏的input承接输入多个视觉上分段的格子呈现数据然后用input事件做格式化。这里Bug高发点在于手机端输入法联想、退格键删除事件、复制粘贴跨分段等。解决思路是用input监听事件重写字段值而不是在格子之间手工移动焦点焦点管理只做视觉定位用。Worker上传大文件核心是让文件切片、哈希计算、上传进度计算不阻塞UI线程。思路是File.slice(start, end)切成块用hashWorker计算文件指纹然后用普通请求或WebSocket逐块上传。实际项目中真正难的不是切片本身而是断点续传——记录已上传的分片索引、服务端合并分片。如果你用阿里云/腾讯云OSS有现成的分片上传API比自己实现省很多事。ECharts闪烁问题最常见的场景是数据更新后setOption被反复调用导致图表重新渲染白屏闪烁。解决办法是区分setOption的第一个参数notMerge和第二个参数lazyUpdatechart.setOption(option, false, true)或者用showLoading配合hideLoading在数据加载期间先占位。另外如果图表在Tab切换后才出现宽度为0导致渲染异常需要在容器可见后再调用chart.resize()。这属于典型的图表渲染时序问题不是ECharts的Bug。4.5 后端怎么跟产品经理确定接口需求最长被忽视的软技能最后想说一个跟架构无关但跟项目成败强相关的话题Java后端怎样和产品经理确定需求。这其实是MVC架构里Model要长什么样的前置问题——如果你连产品要什么都不知道Controller写得再规范也没用。我的经验是三个原则接口文档先行而不是代码先行开工前先画好API文档Swagger/YApi。前端看着文档写代码后端看着文档写实现这样两边不打架。字段尽量语义化别直接暴露数据库表结构给前端返回的VO字段要有业务含义比如userName、createTime而不是u_name、ct这种库字段。状态码设计要统一比如2xx代表成功4xx代表客户端错误5xx代表服务端错误业务错误用code字段区分比如10001表示参数缺失、10002表示无权限。如果状态码混乱前端就得在一个项目里十几种错误处理写法这是很多项目维护地狱的源头。5. 一个从零到一的前后端分离项目MVC思想如何贯穿始终5.1 后端Spring Boot 3 MyBatis MVC 分层架构落地以我最近做的一个管理后台项目为例技术栈是Spring Boot 3.2 MyBatis MySQL Redis整体结构严格按MVC分层backend/ ├── controller/ │ ├── AuthController.java │ ├── UserController.java │ └── OrderController.java ├── service/ │ ├── UserService.java │ └── impl/ ├── mapper/ │ ├── UserMapper.java │ └── xml/ ├── model/ │ ├── entity/ # 数据库实体 │ ├── vo/ # 前端返回值 │ ├── dto/ # 请求参数 │ └── query/ # 分页查询参数 ├── common/ │ └── Result.java # 统一返回体 └── config/ └── MybatisPlusConfig.java这个结构有个必须遵守的规则Controller只依赖Service接口Service只依赖Mapper接口任何跨层跳转都是脏架构。尤其要注意的是Controller里绝不能出现对Mapper的直接注入否则事务、缓存、权限越层处理后续想加一个操作日志或者数据权限时就会无从下手。一个标准的Service实现长这样Service public class UserServiceImpl extends ServiceImplUserMapper, UserEntity implements UserService { Autowired private RedisTemplateString, Object redisTemplate; Override public PageResultUserVO queryUserPage(UserQuery query) { LambdaQueryWrapperUserEntity wrapper new LambdaQueryWrapper(); wrapper.like(StrUtil.isNotBlank(query.getUsername()), UserEntity::getUsername, query.getUsername()) .eq(query.getStatus() ! null, UserEntity::getStatus, query.getStatus()) .orderByDesc(UserEntity::getCreateTime); PageUserEntity page userMapper.selectPage( new Page(query.getPageNum(), query.getPageSize()), wrapper); return PageResult.of(page, UserVO::fromEntity); } }5.2 前端Vue3 Element Plus PiniaView和ViewModel的分界前端部分技术栈用的是Vue3 Vite Element Plus Pinia Axios。目录结构刻意跟后端MVC形成呼应frontend/ ├── api/ │ ├── user.ts # 接口请求定义相当于后端的Controller │ └── order.ts ├── stores/ │ ├── userStore.ts # 全局状态相当于前端的ViewModel │ └── orderStore.ts ├── views/ │ ├── user/ │ │ └── UserList.vue # 视图层纯展示 │ └── order/ ├── components/ # 复用组件 ├── router/ └── utils/ └── request.ts # Axios封装统一处理Token和错误码这里要强调的原则是Vue组件View里尽量不要出现fetch或axios调用。API请求应该统一放到api/目录里组件只负责调用这样做的直接好处是——后端接口变了你只需要改一个文件而不用去翻每一个组件。举个例子用户列表组件里应该是这样的script setup langts import { ref, onMounted } from vue import { getPageUserList } from /api/user import type { UserVO, UserQuery } from /types/user const tableData refUserVO[]([]) const total ref(0) const queryParams reactiveUserQuery({ pageNum: 1, pageSize: 10, username: }) async function loadData() { const { records, total: count } await getPageUserList(queryParams) tableData.value records total.value count } onMounted(() { loadData() }) /script5.3 联调阶段的高频问题与最终落地效果联调阶段是前后端分离项目最容易失控的时期。我的经验总结下来高频问题基本就是这几类字段名/类型不一致前端要userId后端给的是uid或者接口文档写number实际返回字符串。破题之法是用TypeScript类型定义和Java VO字段做一对一映射用代码生成工具如OpenAPI Generator自动生成前后端类型这样两边永远一致。时间时区不一致后端返回2024-06-01T10:00:00Z前端直接new Date()处理结果差8小时。约定标准统一用ISO 8601格式 明确时区前端统一用dayjs做格式化。异常处理不一致后端报错返回{code: 500, message: 系统异常}前端统一在Axios拦截器里处理用户侧提示拿message字段控制台打印完整堆栈。等这些约定都落实到位一个前后端分离项目的体验会非常顺前端组件改样式不需要碰后端、后端改数据结构不影响前端界面、两边可以并行开发互不阻塞——这正是MVC思想释放出来的生产力。6. 回到开头的结论真正要学的不是MVC这个词是分工这件事写到这里我想最后分享一个自己这些年反复体会到的道理。很多人批判MVC批判的是它过时的分层但真正让他们写出来一团乱麻的往往是没有分层的意识而不是分层这个方案本身。每次新项目启动我脑子里过一遍的永远是那套最古老的问题哪些代码是数据的形状哪些代码是数据的流转哪些代码是数据的呈现这三件事分得越清楚项目越容易维护。这不是技术在进化是技术在不断替我们实现这套分工——从ASP.NET Core的Razor页面到Spring MVC的注解驱动到Gin的函数式Handler到FastAPI的类型注入再到Vue的响应式渲染和Flutter的Widget树每一代框架都在让分工这件事变得更省力但从来没说要取消分工。所以如果你现在正在学后端或者正在转前端我的建议是不要只盯着一个框架的语法而是先搞懂你写的Controller/Service/Mapper到底在分工里承担什么角色。把这个问题想明白了你再看任何一个新框架都会觉得似曾相识学起来事半功倍。最后补一句实用的小技巧在团队里把接口文档、统一错误码、返回体格式这些契约上升到代码同等的重视程度。技术选型可以变框架可以换但契约一旦定下来就稳定执行这是我在多个项目里踩过坑之后最想强调的一条落地经验。
返回列表