资讯详情

苍穹外卖Day02:Spring Boot+MyBatis实现用户端数据存取链路

📅 2026/9/30 3:12:06 | 华诺云谱 👁 阅读
苍穹外卖Day02:Spring Boot+MyBatis实现用户端数据存取链路
整个day02砍下来最舒服的其实是那种“一切都在掌控内”的感觉数据库表建完、实体写完、Mapper一配接口一调数据咔咔就出来了。但前提是之前那些坑你没踩进去。我先把话撂这儿苍穹外卖day02的核心就是在做“用户端数据存取链路”。说白了就是让你把“前端页面要什么 → Controller 接收什么 → Service 处理什么 → Mapper 查什么 → 数据库存什么”这条链路彻底打通。如果你现在对Spring Boot框架还停留在“会启动、会写HelloWorld”的层面那么这一天内容建议你慢点过每一步都动手敲一遍甚至敲完再删了重敲一遍。这篇内容适合两类人看。第一类是跟着教程做课程设计或毕设的学生你把里面那些“为什么”看懂答辩时至少能少挨两句怼第二类是刚转行Java后端、想拿个像样的项目往简历上写的新人你得学的不只是CRUD而是为什么这么分、这么查、这么封装以及线上环境最容易坏在哪。1. 整体设计与需求拆解1.1 day02到底要完成哪些事day01一般做了什么无非是把项目骨架搭起来Spring Boot工程建好、MySQL连接配好、基础依赖引进来、然后跑通一个最简单的Controller证明项目能启动。那day02干什么就是从“能启动”推向“能出业务数据”。以苍穹外卖里最常见的用户端场景来说这一天要做的事可以拆成四类建好与用户端展示相关的数据表比如分类表、菜品表、菜品口味表把这些表映射成Java实体类并写好对应的Mapper接口和XML映射文件完成“查询分类列表”“按分类查菜品”这类基础接口打通Service和Controller让前端页面能真正调通接口把JSON数据渲染到页面上。你可能会说这不就是增删改查吗对就是增删改查。但问题在于很多人把增删改查做得太“面向过程”——在Controller里直接写SQL、在Service里直接new一个Mapper然后不管事务、返回数据时直接返回一个Map乱糟糟的字段。day02项目的设计意图就是逼着你把它做规范。我习惯在动手敲代码之前先画一条数据流向页面按钮 → HTTP请求 → Controller参数接收 → Service业务组装 → Mapper SQL执行 → 数据库返回 → 实体类封装 → JSON序列化 → 页面上渲染。你会发现其实整条链路上没有一步需要“炫技”但任何一步偷懒后面联调就叫苦不迭。1.2 为什么非要分成Controller、Service、Mapper三层很多新手不理解我直接在一个类里写个方法上面用GetMapping标注里面直接执行SQL不也能把数据查出来吗能是能但那是教学代码不是工程代码。苍穹外卖day02沿用的三层结构真正的理由有三个。第一是职责边界。Controller层的唯一任务就是接收HTTP参数、调用Service、把结果包装成统一格式返回。它不应该知道SQL长什么样也不该关心“分类数据是从哪张表查出来的”。Service层负责业务逻辑比如查询前校验状态、查询后加工数据、事务控制。Mapper层只管一件事——跟数据库打交道。这样整个代码读下来任何人接手都能很快找到改哪里。第二是事务和安全。日常项目里一个接口往往不只是一条SQL。比如保存菜品的同时还要保存口味两步操作必须保证要么都成功要么都失败。这种事务控制放在Service层天然合适而如果你在Controller里各种操作数据库事务边界就失控了。第三是可测试性。分层的代码单元测试才好写。你可以Mock掉Mapper单独测Service里的业务判断也可以直接对着Mapper的XML查数据定位SQL本身的问题。不分层的话业务逻辑和SQL揉在一起回头排查个bug能把人折磨疯。day02这个项目深挖下去你会发现它选MyBatis而不是JPA、Hibernate也是有意的。MyBatis把SQL交到你手上你清楚每一句查询在干什么。学生阶段和初级开发阶段这比自动生成的SQL更容易建立手感。2. 核心细节解析与实操要点2.1 表结构设计哪些字段必须有哪些字段经常被漏掉前面提到用户端展示菜品至少涉及分类和菜品两张主表外加一个口味表。很多人建表的时候不注意后面开始写联表查询才发现分类名没冗余、状态字段没加、排序字段没建……补表可比建表麻烦多了。先看分类表category的常见设计id主键自增没得说name分类名称通常加unique约束同一个类型下名字不重复type分类类型1表示菜品分类、2表示套餐分类。这个字段在用的时候很多人会忽略导致前端展示时把套餐和菜品混一起sort排序权重数字越小越靠前status状态0停用、1启用前端只展示启用的create_time、update_time创建时间和更新时间day02可能还没做自动填充但字段一定要先建上后面做拦截器或MyBatis自动填充时直接用。再说菜品表dish。这里容易漏的有两个字段一个是category_id它是外键字段但不一定非要建物理外键只作为逻辑关联即可另一个是image存放图片URL。有些人把这个字段省了结果前端页面全是裂图占位。菜品口味表dish_flavor也别随便建。它的核心是一对多关系一个菜品可以有多个口味。设计时除了id、dish_id还要有name和value两个字段比如“辣度”和“微辣、中辣、特辣”。value用varchar存逗号分隔也行但更规范的做法是一行一个口味。外表结构定完有件事必须做统一字段命名风格。MySQL里常见两种风格全小写下划线比如create_time或者驼峰createTime。如果表字段用下划线、实体类用驼峰那么MyBatis的驼峰映射开关必须打开否则查出来的create_time在Java对象里永远是null。这个开关后半部分我会演示具体配置。还有一点设计表时尽量别用MySQL的保留字当表名或字段名。你比如有个功能要存订单直接建一张order表完了order是排序关键字每次查询都得加反引号。day02暂时不碰订单但养成习惯吧遇到user、order这类词要么前缀要么加反引号别给自己埋雷。2.2 实体类与数据库字段的对应关系建完表就该写Java实体类了。这里头有些细节写的时候总觉得“不对也能跑”但跑起来就一个接一个坑。第一实体类字段名要和表字段按驼峰规则映射。比如表里是category_id实体类就得是categoryId。MySQL字段全小写下划线Java字段驼峰命名这个约定要是乱了后面每写一条SQL都要用as别名去纠正累死人。第二Boolean字段千万别加is前缀。我给你举个真实翻车场景表字段叫status实体类你写isStatus你以为没问题但实际上JavaBean的规范是boolean类型字段名叫isStatus时生成的getter方法会变成getStatus有些框架是isStatus序列化时字段名可能直接变status而不是isStatus前后端一对就是各种对不上。更稳妥的写法是字段名就叫status类型用Integer或Byte0和1表示状态不要用Boolean。第三时间字段的类型选择。MySQL的datetime映射成Java的LocalDateTime在MyBatis 3.4.5以上版本配合JSR-310是没问题的但需要检查你的pom里有没有带jackson-datatype-jsr310依赖。不加的话LocalDateTime序列化会直接报错或者输出一串让你崩溃的数组。Spring Boot 2.x的web起步依赖里会带但如果你手动管理依赖一定记得确认。实体类写完别急往下走先做一件事用Lombok把Data加上把无参构造、全参构造弄好。然后启动一次项目什么业务都别写先看看控制台有没有报错。项目启动不报错不代表没问题但至少你的Bean创建、注解扫描是正常的。2.3 XML映射文件里最容易翻车的几个地方MyBatis的Mapper接口和XML映射文件是day02里出问题最多的区域。我在带人做项目时说过一句话XML里错一个符号能让你查一下午。先说namespace。XML文件顶部的namespace必须写Mapper接口的全限定名也就是包名加接口名。写错的话运行时会直接告诉你找不到Statement。这属于低级错误但越是低级错误越容易在复制粘贴中翻车。再说SQL语句里的id。XML里每个、 的id必须和Mapper接口里的方法名一模一样。接口方法listCategoryXML里的select id就写listCategory。大小写要一致别问为什么MyBatis就认这个。参数传递是另一个高频雷区。一个参数时你可以不写Param注解XML里直接用#{xxx}但要保证两边名字能对应上。多个参数时强烈建议每个参数都加Param注解。比如接口方法List listByCategoryId(Param(categoryId) Long categoryId, Param(status) Integer status)XML里就写#{categoryId}和#{status}。不加Param的情况下你用#{0}、#{1}也能取但代码可读性极差回头改需求的时候连你自己都看不懂0和1分别是谁。动态SQL里 标签的取舍也值得说。一个常见场景按分类ID查菜品但分类ID可能为空为空时查全部。你可能会写select idlistDish resultTypecom.example.pojo.Dish select * from dish where 11 if testcategoryId ! null and category_id #{categoryId} /if if teststatus ! null and status #{status} /if /select这个写法能用加where 11是为了拼SQL方便但不够优雅。更好的做法是用 标签自动处理掉多余的andselect idlistDish resultTypecom.example.pojo.Dish select * from dish where if testcategoryId ! null and category_id #{categoryId} /if if teststatus ! null and status #{status} /if /where order by sort asc /select还有#{}和${}的区别。day02阶段你只需要记住一条铁律用户传进来的参数永远用#{}。${}是字符串拼接存在SQL注入风险。哪怕你觉得查询条件里需要动态拼表名或排序字段也先想别的办法绕开它。这不是技巧问题是安全底线。3. 实操过程与核心环节实现3.1 从页面到数据库分类列表查询的完整链路我以“用户端首页展示分类列表”为例把整个链路从头到尾串一遍。这个场景在day02里很典型做完它你就明白了这项目三层之间到底怎么互相调用。第一步先建Controller。目录结构一般是controller、service、mapper三个包Controller里新建CategoryControllerRestController RequestMapping(/category) public class CategoryController { Resource private CategoryService categoryService; GetMapping(/list) public ResultListCategory list(CategoryQuery query) { ListCategory list categoryService.listCategory(query); return Result.success(list); } }注意几个细节。RestController是Controller加ResponseBody的组合表示这个类的所有方法返回JSON不走视图解析器。RequestMapping(/category)定义了类级别的路径前缀方法上再加/list就拼成了/category/list。这样设计的好处是如果后续要加category模块的接口只需要在类里面继续加方法就行路径统一管理。CategoryQuery是一个查询条件对象里面可以放type、status这些可选条件。如果你不喜欢额外建一个Query类也可以直接把参数写进方法签名比如list(RequestParam(required false) Integer type)。都可以但我个人喜欢Query对象因为条件一多方法签名会变得又臭又长。第二步写Service接口和实现类。接口public interface CategoryService { ListCategory listCategory(CategoryQuery query); }实现类Service public class CategoryServiceImpl implements CategoryService { Resource private CategoryMapper categoryMapper; Override public ListCategory listCategory(CategoryQuery query) { if (query null) { query new CategoryQuery(); } return categoryMapper.list(query); } }你看到没这个Service层现在看起来“很薄”好像没什么业务逻辑。但它的价值在于当未来需求变了比如查询分类列表前要校验当前用户是否登录、要过滤掉某些特殊分类你只需要改这一层接口签名和调用方式都不动。控制层和控制层之间是稳定的真正变化的地方被关在Service内部。第三步Mapper接口Mapper public interface CategoryMapper { ListCategory list(CategoryQuery query); }我在开发时习惯给Mapper接口加Mapper注解同时也在Spring Boot启动类上加上MapperScan(com.example.mapper)。两个都加不冲突加了MapperScan之后接口上不加Mapper也能被扫描到。如果你不用MapperScan就必须在每个Mapper接口上写Mapper漏一个就报错实话讲挺烦的。第四步XML映射文件。resouces/mapper/CategoryMapper.xml?xml version1.0 encodingUTF-8? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.mapper.CategoryMapper select idlist resultTypecom.example.pojo.Category select * from category where if testtype ! null and type #{type} /if if teststatus ! null and status #{status} /if /where order by sort asc, id asc /select /mapper这段XML配置完别急着启动。先检查两个地方namespace是不是你的Mapper接口全限定名、id是不是方法名list。确认无误再检查application.yml里的mapper-locations配置mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.pojo configuration: map-underscore-to-camel-case: truetype-aliases-package配置好以后XML里的resultType就可以只写Category不用写全限定名com.example.pojo.Category了。map-underscore-to-camel-case就是我们前面反复提到的驼峰映射开关设成true后create_time自动映射到createTime省得每条SQL都写别名。配置到位后启动项目访问http://localhost:8080/category/list?type1status1浏览器里要么直接返回JSON要么通过Postman看一眼结果。这一整套跑通day02里最核心的链路就已经通了。3.2 按分类查菜品联表查询与VO的设计分类列表做完接着做“按分类查菜品”。这个功能比分类列表多了一层复杂度菜品表和分类表是分开的菜品表里只有category_id没有categoryName但前端页面需要展示分类名。也就是说你不能简单select * from dish你得join一下分类表把分类名查出来。一种做法是直接返回一个Map把需要的字段塞进去。这种做法能跑但我在项目里不推荐因为Map的key没有类型约束前后端字段对不上时报错都很难发现。更规范的做法是定义一个VOView Object类比如DishVOData public class DishVO { private Long id; private String name; private String image; private String description; private BigDecimal price; private Integer status; private Long categoryId; private String categoryName; }注意DishVO里包含Dish表本身的字段也包含categoryName这个来自分类表的冗余展示字段。它的定位是“给前端看的对象”可以和实体类不同。DishMapper接口Mapper public interface DishMapper { ListDishVO listWithCategory(DishQuery query); }XMLselect idlistWithCategory resultTypecom.example.pojo.DishVO select d.id, d.name, d.image, d.description, d.price, d.status, d.category_id, c.name as categoryName from dish d left join category c on d.category_id c.id where if testcategoryId ! null and d.category_id #{categoryId} /if if teststatus ! null and d.status #{status} /if /where order by d.sort asc, d.id asc /select这里我写的是left join而不是inner join。为什么因为有些菜品可能还没分配分类left join能保证这类菜品仍然出现在结果集里categoryName为null而已。用户端要是查不到这些“孤儿菜品”前端页面会少东西后台管理时就会疑惑我明明录入菜品了为什么前端不显示当然实际业务里这类菜品本来就该由审核流程筛掉但left join更稳妥。联表查询时我给d表起了别名。可能有人想字段名都一样不写别名也能查吧你试试看如果两张表里都有name这个字段select *或者select name时MySQL会直接报错列名不明确。即使不报错字段映射也会错位。所以联表查询时养成给表起别名的习惯查询字段一律写别名点字段名。实体类和VO之间的转换可以直接用Spring BeanUtils.copyProperties也可以手写setter。day02的数据量小手写setter虽然啰嗦但其实最直观。我推荐你在学习阶段尽量手写一遍把每个字段的来龙去脉摸清楚后面再用工具类偷懒也不迟。3.3 统一返回结果与日期格式化前面代码里我反复用到Result.success(list)这就是苍穹外卖项目里的统一返回结果类。它的设计其实很朴素Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(1); result.setMsg(成功); result.setData(data); return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.setCode(0); result.setMsg(msg); return result; } }关于code用1还是200不同教程有不同约定。我之前带的项目里用1表示成功0表示失败前端拿着这个code判断是否弹Toast。你可以统一成200也行但前提是前端同学和你确认好别后端一套前端一套联调时打起来。还有一件很烦的事就是日期格式化。菜品创建时间createTime要是直接以LocalDateTime对象返回给前端前端默认接收到的是一个数组或者带T的字符串展示出来很难看。解决办法有三个在实体类的日期字段上写JsonFormat(pattern yyyy-MM-dd HH:mm:ss)简单粗暴在application.yml里全局配置spring.jackson.date-format和time-zone只对java.util.Date生效对LocalDateTime不一定管用配置Jackson的ObjectMapper注册JavaTimeModule再设置LocalDateTime的序列化格式。day02阶段我建议用方案一在VO的日期字段上写JsonFormat注解因为这样影响范围最小你清楚这个字段出去长什么样。等后面项目越做越大再考虑全局配置也不迟。4. 常见问题与排查技巧实录4.1 接口查出来一直是空列表问题可能出在哪这是day02里最高频的求助帖类型。我遇到一个同学SQL在Navicat里跑得好好的一到接口调用就返回空数组查了半小时没头绪。我让他先确认日志结果发现MyBatis打印出来的SQL语句里传进去的status值是0而他在SQL客户端里测试时用的status是1。问题在哪在Mapper接口和Service之间的参数传递。Controller接收前端参数时前端没传statusInteger类型的status就是null这没问题。但有些人在Service里自作主张写了个if (query.getStatus() null) { query.setStatus(0); }默认把status设成了0结果反而把“启用”的数据过滤没了。这种默认值逻辑加的时候一定要想清楚业务含义。另一个常见原因就是我前面强调过的驼峰映射没开。假如数据库里字段是is_enabled实体类是enabled或者isEnabled开关没开时查询结果里这一列永远是null。可见map-underscore-to-camel-case这行配置有多么重要。你如果不想依赖这个开关也可以在SQL里用as别名但那就属于在源头上给自己找事了。还有一类情况很隐蔽表名大小写。Linux服务器上MySQL的表名是区分大小写的你在Windows上建表叫CategoryLinux上写SQL查category就会直接报错。解决办法是建表时统一用小写表名查询时也全小写别一会儿驼峰一会儿下划线。4.2 查询结果字段错乱、日期映射失败字段错乱大多出在联表查询没有起别名或者resultType写错。比如我上面写的listWithCategory如果select里直接用name恰好分类表和菜品表都有name结果就是所有菜品的name都被分类的name覆盖了。这种错乱不报错但显示出来的数据会让人一头雾水排查时一定要先看SQL日志再把SQL复制到Navicat里执行对比列名和结果集。日期映射失败最典型的表现是项目启动时报错Java 8 date/time type java.time.LocalDateTime not supported by default。这个错误基本上可以断定是缺少JSR310模块支持或者你用的MyBatis版本太老。解决办法分两步先把pom里MyBatis相关依赖升到3.5.x以上再检查pom里有没有加jackson-datatype-jsr310。如果你用的是Spring Boot把mybatis-spring-boot-starter升到2.1.x以上官方已经帮你处理了大部分问题。如果你发现接口返回的日期字段长这样2024-01-01T12:00:00那是因为Jackson默认的序列化格式不带空格。加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)之后基本上就正常了。注意timezone别漏不然传回前端的时间可能比数据库时间少8小时。这个坑我在一个从零搭的项目里踩过页面显示的时间总比实际慢8小时排查时一度以为是数据库时区问题最后发现就是JSON序列化没指定GMT8。4.3 前端页面联调时的几个典型问题前端的Vue页面调接口时报跨域这是最常见的联调问题。浏览器直接访问http://localhost:8080/category/list没问题但从http://localhost:5173的Vite开发服务器发请求就会被拦截。解决办法有几个在Spring Boot里加CORS配置类用CrossOrigin注解但需要加到每个Controller类上麻烦让前端在Vite配置里设置proxy代理把/api开头的请求转发到后端这样浏览器看同源。我推荐先用后端的CORS配置类因为它对前端侵入性最小改完后端代码前端不用动。配置类大致长这样Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里提醒一个点allowedOriginPatterns()和allowCredentials(true)配合使用时不能写成allowedOrigins()否则浏览器会因为响应头无法匹配而报错。这个细节网上很多教程都不提但实测下来非常重要。还有一件事改完Java代码后前端页面调接口还在用旧数据。这大概率不是后端没改而是前端页面或者浏览器缓存了旧响应。F12打开Network勾选Disable cache再刷新试试。另外如果你的后端是一个普通Spring Boot Jar包改了代码需要重启别以为有热部署就万事大吉有时热部署没生效你改的代码压根没加载进当前进程。5. 一些习惯与后续扩展想法5.1 做这套项目时强烈建议养成的习惯自己写了两遍苍穹外卖也带别人写过有个习惯很想分享写完一个接口先不联前端直接在Postman里把正常情况、缺参情况、错误情况全部打一遍。为什么因为前端联调阶段你一旦被跨域、参数名对不上、字段类型不一致这些低级问题绊住你的注意力就会被分散而那些接口逻辑本身的错误反而没被发现。还有SQL日志一定要开。在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样MyBatis会把执行的SQL和参数打印到控制台。你每次调接口都能清清楚楚看到SQL是怎么拼出来的。这个日志在生产环境一般会关掉但开发阶段必须开着。每隔一段时间就做一次代码自查看看Controller是不是写得太胖、Service是不是有用不到的注入、Mapper接口是不是没有对应XML却也能跑因为用了注解SQL。这些习惯养好了后面day03、day04的内容你会越写越顺。5.2 给day03的一点预告按苍穹外卖常见的课程节奏day03一般开始做管理端功能或者给用户端接口加上分页、缓存、权限控制。到时候你在day02写的这套链路几乎原样复用只是加更多的查询条件、更多的动态SQL、更复杂的VO结构。所以如果你现在觉得有点吃力回头把day02的表结构和Mapper XML再多看两遍。这些才是后面所有功能的底座。我个人实际体会是day02最值得花时间的地方不是“把代码跑通”而是“把每条SQL为什么这么写搞清楚”。分类表和菜品表的字段还能加什么索引、联表查询走没走索引、status过滤放在SQL里还是业务代码里——这些问题你现在会想了等做到后面千万级数据量的查询场景时你就知道省了多少麻烦。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑