拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

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

苍穹外卖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或Byte,0和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接口里的方法名一模一样。接口方法listCategory,XML里的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 id="listDish" resultType="com.example.pojo.Dish"> select * from dish where 1=1 <if test="categoryId != null"> and category_id = #{categoryId} </if> <if test="status != null"> and status = #{status} </if> </select>

    这个写法能用,加where 1=1是为了拼SQL方便,但不够优雅。更好的做法是用 标签自动处理掉多余的and:

    <select id="listDish" resultType="com.example.pojo.Dish"> select * from dish <where> <if test="categoryId != null"> and category_id = #{categoryId} </if> <if test="status != null"> and status = #{status} </if> </where> order by sort asc </select>

    还有#{}和${}的区别。day02阶段,你只需要记住一条铁律:用户传进来的参数,永远用#{}。${}是字符串拼接,存在SQL注入风险。哪怕你觉得查询条件里需要动态拼表名或排序字段,也先想别的办法绕开它。这不是技巧问题,是安全底线。

    3. 实操过程与核心环节实现

    3.1 从页面到数据库:分类列表查询的完整链路

    我以“用户端首页展示分类列表”为例,把整个链路从头到尾串一遍。这个场景在day02里很典型,做完它,你就明白了这项目三层之间到底怎么互相调用。

    第一步,先建Controller。目录结构一般是controller、service、mapper三个包,Controller里新建CategoryController:

    @RestController @RequestMapping("/category") public class CategoryController { @Resource private CategoryService categoryService; @GetMapping("/list") public Result<List<Category>> list(CategoryQuery query) { List<Category> 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 { List<Category> listCategory(CategoryQuery query); }

    实现类:

    @Service public class CategoryServiceImpl implements CategoryService { @Resource private CategoryMapper categoryMapper; @Override public List<Category> listCategory(CategoryQuery query) { if (query == null) { query = new CategoryQuery(); } return categoryMapper.list(query); } }

    你看到没,这个Service层现在看起来“很薄”,好像没什么业务逻辑。但它的价值在于:当未来需求变了,比如查询分类列表前要校验当前用户是否登录、要过滤掉某些特殊分类,你只需要改这一层,接口签名和调用方式都不动。控制层和控制层之间是稳定的,真正变化的地方被关在Service内部。

    第三步,Mapper接口:

    @Mapper public interface CategoryMapper { List<Category> list(CategoryQuery query); }

    我在开发时习惯给Mapper接口加@Mapper注解,同时也在Spring Boot启动类上加上@MapperScan("com.example.mapper")。两个都加不冲突,加了@MapperScan之后,接口上不加@Mapper也能被扫描到。如果你不用@MapperScan,就必须在每个Mapper接口上写@Mapper,漏一个就报错,实话讲挺烦的。

    第四步,XML映射文件。resouces/mapper/CategoryMapper.xml:

    <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.mapper.CategoryMapper"> <select id="list" resultType="com.example.pojo.Category"> select * from category <where> <if test="type != null"> and type = #{type} </if> <if test="status != 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: true

    type-aliases-package配置好以后,XML里的resultType就可以只写Category,不用写全限定名com.example.pojo.Category了。map-underscore-to-camel-case就是我们前面反复提到的驼峰映射开关,设成true后,create_time自动映射到createTime,省得每条SQL都写别名。

    配置到位后,启动项目,访问http://localhost:8080/category/list?type=1&status=1,浏览器里要么直接返回JSON,要么通过Postman看一眼结果。这一整套跑通,day02里最核心的链路就已经通了。

    3.2 按分类查菜品:联表查询与VO的设计

    分类列表做完,接着做“按分类查菜品”。这个功能比分类列表多了一层复杂度:菜品表和分类表是分开的,菜品表里只有category_id,没有categoryName,但前端页面需要展示分类名。也就是说,你不能简单select * from dish,你得join一下分类表,把分类名查出来。

    一种做法是直接返回一个Map,把需要的字段塞进去。这种做法能跑,但我在项目里不推荐,因为Map的key没有类型约束,前后端字段对不上时报错都很难发现。更规范的做法是定义一个VO(View Object)类,比如DishVO:

    @Data 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 { List<DishVO> listWithCategory(DishQuery query); }

    XML:

    <select id="listWithCategory" resultType="com.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 test="categoryId != null"> and d.category_id = #{categoryId} </if> <if test="status != 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 Result<T> { private Integer code; private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(1); result.setMsg("成功"); result.setData(data); return result; } public static <T> Result<T> error(String msg) { Result<T> 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接收前端参数时,前端没传status,Integer类型的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上建表叫Category,Linux上写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 = "GMT+8")之后,基本上就正常了。注意timezone别漏,不然传回前端的时间可能比数据库时间少8小时。这个坑我在一个从零搭的项目里踩过,页面显示的时间总比实际慢8小时,排查时一度以为是数据库时区问题,最后发现就是JSON序列化没指定GMT+8。

    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里还是业务代码里——这些问题你现在会想了,等做到后面千万级数据量的查询场景时,你就知道省了多少麻烦。

    返回列表