第二期 - 后端开发
第二期 - 后端开发
面试吧笔记第二期 - 后端开发
开发面试吧后端基础功能。
1. 需求分析
目标:明确要做的需求,并且给需求设置优先级,从而明确开发计划。
根据核心业务业务流程,明确需求开发的优先级:
- 为核心,非做不可
- 为重点功能,最好做
- 为实用功能,有空就做
- 可做可不做,时间充裕再做
确定优先级的需求列表如下。
1.1 基础功能(均为 P0)
用户模块
- 用户注册
- 用户登录(账号密码)
- 【管理员】管理用户-增删改查
题库模块
- 查看题库列表
- 查看题库详情(展示题库下的题目)
- 【管理员】管理题库-增删改查
题目模块
- 题目搜索
- 查看题目详情(进入刷题页面)
- 【管理员】管理题目-增删改查(按照题库查询题目、修改题目所属题库等)
1.2 高级功能(均为 P1 ~ P2)
题目批量管理
- 【管理员】批量向题库添加题目
- 【管理员】批量从题库移除题目
- 【管理员】批量删除题目
分词题目搜索
用户刷题记录日历图
自动缓存热门题目
网站流量控制和熔断
动态 IP 黑白名单过滤
同端登录冲突检测
分级题目反爬虫策略
2. 库表设计
2.1用户表
用户表的核心是用户登录凭证(账号密码)和个人信息,SQL 如下:
-- 用户表
create table if not exists user
(
id bigint auto_increment comment 'id' primary key,
userAccount varchar(256) not null comment '账号',
userPassword varchar(512) not null comment '密码',
unionId varchar(256) null comment '微信开放平台id',
mpOpenId varchar(256) null comment '公众号openId',
userName varchar(256) null comment '用户昵称',
userAvatar varchar(1024) null comment '用户头像',
userProfile varchar(512) null comment '用户简介',
userRole varchar(256) default 'user' not null comment '用户角色:user/admin/ban',
editTime datetime default CURRENT_TIMESTAMP not null comment '编辑时间',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
index idx_unionId (unionId)
) comment '用户' collate = utf8mb4_unicode_ci;
其中,unionld
、mpopenld
是为了实现公众号登录的,也可以省略。每个微信用户在同一家公司(主体)的 unionld
是唯一的,在同一个公众号的 mpOpenld
是唯一的。editTime
和 updateTime
的区别是:editTime
表示用户编辑个人信息的时间(需要业务代码来更新),而 updateTime
表示这条用户记录任何字段发生修改的时间(由数据库自动更新)。
2.2 题库表
题库表的核心是题库信息(标题、描述、图片),SQL 如下:
-- 题库表
create table if not exists question_bank
(
id bigint auto_increment comment 'id' primary key,
title varchar(256) null comment '标题',
description text null comment '描述',
picture varchar(2048) null comment '图片',
userId bigint not null comment '创建用户 id',
editTime datetime default CURRENT_TIMESTAMP not null comment '编辑时间',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
index idx_title (title)
) comment '题库' collate = utf8mb4_unicode_ci;
其中,picture
存储的是图片的 url 地址,而不是完整图片文件。通过 userld
和用户表关联,在本项目中只有管理员才能创建题库。
由于用户很可能按照标题搜索题库,所以给 title
字段增加索引。
2.3 题目表
题目表的核心是题目信息(标题、详细内容、标签),SQL 如下:
-- 题目表
create table if not exists question
(
id bigint auto_increment comment 'id' primary key,
title varchar(256) null comment '标题',
content text null comment '内容',
tags varchar(1024) null comment '标签列表(json 数组)',
answer text null comment '推荐答案',
userId bigint not null comment '创建用户 id',
editTime datetime default CURRENT_TIMESTAMP not null comment '编辑时间',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
index idx_title (title),
index idx_userId (userId)
) comment '题目' collate = utf8mb4_unicode_ci;
几个重点:
- 题目标题
title
和题目创建人userld
是常用的题目搜索条件,所以添加索引提升查询性能。 - 题目可能有多个标签,为了简化设计,没有采用关联表,而是以 JSON 数组字符串的方式存储,比如
["Java","Python"]
。 - 题目内容(详情)和题目答案可能很长,所以使用
text
类型。
2.4 题库题目关联表
由于一个题库可以有多个题目,一个题目可以属于多个题库,所以需要关联表来实现。
实现基础功能的 SQL 如下:
-- 题库题目表(硬删除)
create table if not exists question_bank_question
(
id bigint auto_increment comment 'id' primary key,
questionBankId bigint not null comment '题库 id',
questionId bigint not null comment '题目 id',
userId bigint not null comment '创建用户 id',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
UNIQUE (questionBankId, questionId)
) comment '题库题目' collate = utf8mb4_unicode_ci;
几个重点:
上述代码中的
userld
表示添加题目到题库的用户 id,仅管理员可操作。由于关联表中的数据记录并没有那么重要(一般由管理员维护),所以直接采用硬删除的方式,如果将题目移出题库,直接删掉对应的数据即可。按照这种设计,
createTime
就是题目加入到题库的时间。通过给题库 id 和题目id 添加联合唯一索引,防止题目被重复添加到同一个题库中。而且要注意,将
questionBankd
放到前面,因为数据库中的查询大多是基于questiongankId
进行的,比如査找某个题库中的所有问题,或者在一个题库中查找特定问题,将questionBankId
放在前面符合查询模式,会使得这些查询更加高效(索引的最左前缀原则)。
3. 后端基础开发
使用 后端万用模板 对后端项目初始化,接着全局替换模块名和包名,移除不必要的模块,执行数据库初始化文件,最后修改配置文件启动项目即可。
3.1 数据实体层
使用 MyatisX 代码生成插件快速得到 mapper 和数据库实体类,生成后移动到项目对应位置( mapper 和 model.entity 包),如下图所示:
接着修改生成的数据库实体类的字段配置,指定主键策略为 ASSIGN_ID
雪花算法生成 和 添加逻辑删除注解。
比如题目表的示例代码:
/**
* 题目
*
* @author BraumAce
* @TableName question
*/
@TableName(value ="question")
@Data
public class Question implements Serializable {
/**
* id,指定雪花算法生成
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
// ...
/**
* 是否删除(逻辑删除)
*/
@TableLogic
private Integer isDelete;
}
3.2 业务逻辑层
使用万用模版的代码生成器工具(CodeGenerator)生成代码,包括:Controller、Service 接口和实现类、数据模型包装类。如下所示:

只需要修改生成参数即可。
运行后生成的代码在根目录的 generator 包下,我们根据需要移动到对应的位置,包括 controller、service、model.dto、model.vo,然后再根据实际开发修改部分代码即可使用。
3.3 数据模型层
需要编写数据模型包装类(dto 包的请求类和 vo 包的视图类)、JSON 结构对应的类、枚举类。
其中,包装类需要根据前端实际传递的请求参数或需要的响应结果自行修改。
以创建题目请求包装类 QuestionAddRequest 为例,需要保留前端需要的字段,并且将 JSON 字符串字段转换为前端更好理解的数据类型(比如 tags
由 String 转为 List),还有一些由后端自动生成的字段(比如 userld
、createTime
)就不用写到类里了。代码如下:
package com.yuan.mianshiba.model.dto.question;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 创建题目请求
*
* @author BraumAce
*/
@Data
public class QuestionAddRequest implements Serializable {
/**
* 标题
*/
private String title;
/**
* 内容
*/
private String content;
/**
* 标签列表
*/
private List<String> tags;
/**
* 推荐答案
*/
private String answer;
private static final long serialVersionUID = 1L;
}
其他请求包装类同理。
需要结合具体的业务、实体类、创建表的 DDL 语句去编写请求包装类。
3.4 接口开发
修改生成的 Controller 接口,不需要包含业务逻辑,处理字段不一致的问题、并且将无用的接口移除。
目前能跑通即可。
3.5 服务开发
修改生成的 Service 接口和实现类,处理字段不一致的问题,略微调整数据校验、查询条件等代码。
目前能跑通即可。
3.6 Swagger 接口文档测试
运行项目,通过访问 Swagger 接口文档来测试增删改查接口能否正常执行。
地址:http://localhost:8101/api/doc.html#/home
4. 后端核心业务开发
通过万用模板已完成用户模块的开发,包括用户注册、用户登录、管理用户 - 增删改查。
剩下题库和题目两个模块:
题库模块
- 查看题库列表:分页获取题库接口(已生成)
- 查看题库详情(展示题库下的题目):根据 id 获取题库详情接口(需开发)⭐
- 【管理员】管理题库 - 增删改查(已生成)
题目模块
- 题目搜索:分页获取题目接口(已生成)
- 查看题目详情(进入刷题页面):根据 id 获取题目详情接口(已生成)
- 【管理员】管理题目 - 增删改查(已生成)
- 【管理员】按照题库查询题目:根据题库 id 获取题目列表接口(需开发)⭐
- 【管理员】修改题目所属题库:修改题目所属题库接口(需开发)⭐
4.1 题库模块
4.1.1 获取题库详情
需求:根据题库 id 获取题库详情,同时可能需要查询到题库内的题目列表。
分析:考虑到需要适配多种不同的情况,额外用一个字段判断是否要关联查询题目列表。
前端根据不同的场景,给字段传入不同的值。这样的话,对于有些不需要关联查询题目列表的页面,就能够减少性能损耗。
查询时,先从题库表里查询出题库信息,再复用 题目模块中获取题目列表 的服务层接口,获取到题目列表,再封装返回值即可。
具体实现:
- 给 QuestionBankQueryRequest 增加字段
needQueryQuestionList
,用于控制是否要关联查询题目列表。默认为 ,表示不查询。
public class QuestionBankQueryRequest extends PageRequest implements Serializable {
// 省略其他字段...
/**
* 是否要关联查询题目列表
*/
private boolean needQueryQuestionList;
private static final long serialVersionUID = 1L;
}
- 修改题库响应封装类,补充题目列表分页(也可以是列表,根据实际需求调整)。
public class QuestionBankVO implements Serializable {
// 省略其他字段...
/**
* 题库里的题目列表(分页)
*/
Page<Question> questionPage;
}
- 在 QuestionBankController 中修改 “根据 id 获取题库” 封装类的接口,代码如下:
/**
* 根据 id 获取题库(封装类)
*
* @param questionBankQueryRequest
* @param request
* @return
*/
@GetMapping("/get/vo")
public BaseResponse<QuestionBankVO> getQuestionBankVOById(QuestionBankQueryRequest questionBankQueryRequest, HttpServletRequest request) {
ThrowUtils.throwIf(questionBankQueryRequest == null, ErrorCode.PARAMS_ERROR);
Long id = questionBankQueryRequest.getId();
ThrowUtils.throwIf(id <= 0, ErrorCode.PARAMS_ERROR);
// 查询数据库
QuestionBank questionBank = questionBankService.getById(id);
ThrowUtils.throwIf(questionBank == null, ErrorCode.NOT_FOUND_ERROR);
// 查询题库封装类
QuestionBankVO questionBankVO = questionBankService.getQuestionBankVO(questionBank, request);
// 是否要关联查询题库下的题目列表
boolean needQueryQuestionList = questionBankQueryRequest.isNeedQueryQuestionList();
if (needQueryQuestionList) {
QuestionQueryRequest questionQueryRequest = new QuestionQueryRequest();
questionQueryRequest.setQuestionBankId(id);
Page<Question> questionPage = questionService.listQuestionByPage(questionQueryRequest);
questionBankVO.setQuestionPage(questionPage);
}
// 获取封装类
return ResultUtils.success(questionBankVO);
}
4.2 题目模块
4.2.1 获取题目列表
需求:根据题库 id 获取该题库所包含的题目列表(仅管理员可用)。
分析:由于同一个题库内的题目不会很多,为了简化实现,可以直接获取全部题目。
注意,该功能要抽象成 service 方法,便于后续的获取题目列表接口复用。
由于题目和题库是通过关联表维护关系的,所以在查询时,要先通过查询关联表得到题目 id,再根据 id 从题目表查询到题目的完整信息。
有 种实现方式:
通过 SQL 的
join
联表查询:SELECT q.id, q.title, q.content, q.tags, q.answer, q.userId, q.createTime, q.updateTime FROM question_bank_question qbq JOIN question q ON qbq.questionId = q.id WHERE qbq.questionBankId = ?; -- 在这里替换 ? 为具体的题库 id
业务层分步查询
先通过查询关联表得到题目 id,再把 id 放到集合中,根据 id 使用
in
查询从题目表查询到题目的完整信息。
此处选择第二种方式,因为关联逻辑并不复杂、数据量也不大,业务层实现更灵活,也便于组合其他条件去过滤题目列表。
注意
如果要对题目题库关联表和题目表同时进行过滤和分页查询,那么考虑使用 SQL 的 join
实现。
因为业务层处理多表分页比较麻烦。
具体实现:
- 在题目查询请求类 QuestionQueryRequest 中补充 “题库 id” 请求参数 ——
questionBankId
。
public class QuestionQueryRequest extends PageRequest implements Serializable {
// 省略其他字段...
/**
* 题库 id
*/
private Long questionBankId;
}
- 该接口本质上还是查询题目列表,可以把题库 id 当做一个过滤题目的查询条件,所以可以在 QuestionService 中编写一个通用的分页获取题目列表的方法。
public Page<Question> listQuestionByPage(QuestionQueryRequest questionQueryRequest) {
long current = questionQueryRequest.getCurrent();
long size = questionQueryRequest.getPageSize();
// 题目表查询条件
QueryWrapper<Question> queryWrapper = this.getQueryWrapper(questionQueryRequest);
// 根据题库查询题目id列表
Long questionBankId = questionQueryRequest.getQuestionBankId();
if (questionBankId != null) {
// 查询题库内的题目列表
LambdaQueryWrapper<QuestionBankQuestion> lambdaQueryWrapper = Wrappers.lambdaQuery(QuestionBankQuestion.class)
.select(QuestionBankQuestion::getQuestionId)
.eq(QuestionBankQuestion::getQuestionBankId, questionBankId);
List<QuestionBankQuestion> questionList = questionBankQuestionService.list(lambdaQueryWrapper);
if (CollUtil.isNotEmpty(questionList)){
// 取出题目id集合
Set<Long> questionIdSet = questionList.stream()
.map(QuestionBankQuestion::getQuestionId)
.collect(Collectors.toSet());
queryWrapper.in("id", questionIdSet);
}
}
// 查询数据库
Page<Question> questionPage = this.page(new Page<>(current, size), queryWrapper);
return questionPage;
}
- 在 QuestionController 种调用接口。
/**
* 分页获取题目列表(仅管理员可用)
*
* @param questionQueryRequest
* @return
*/
@PostMapping("/list/page")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<Question>> listQuestionByPage(@RequestBody QuestionQueryRequest questionQueryRequest) {
ThrowUtils.throwIf(questionQueryRequest == null, ErrorCode.PARAMS_ERROR);
// 查询数据库
Page<Question> questionPage = questionService.listQuestionByPage(questionQueryRequest);
return ResultUtils.success(questionPage);
}
提示
- 查询关联表的时候,不要 select 所有字段,只取
questionId
就够了,可以提升性能。 - 注意判断通过关联表查询到的题目列表,如果为空,不用再作为查询条件。
- 从关联表查到的虽然只有一个字段,但返回的还是对象,所以需要使用 Lambda 表达式转换成题目 id 集合。
- 把题库 id 通过关联表转换为了多个题目 id,巧妙地复用了原有的题目过滤条件(QueryWrapper),可以同时支持按照题库 id 和标题等其他条件来搜索题目。
4.2.2 修改题目所属题库
需求:根据题库 id 和题目 id,修改题目所属题库。
分析:修改的本质包括添加和删除。
- 添加:题目未加入该题库,管理员可以添加题目到题库,在题库题目关联表中添加一条记录。
- 删除:题目已加入该题库,管理员可以从题库中移除题目,将题目题目关联表中已有的记录删除。
所以需要开发 个接口:创建题库题目关联,移除题库题目关联。
由于在题库题目关联表中已经添加了 题库id、题目id
的唯一性约束,不用担心重复添加脏数据,做好异常处理即可。
注意
如果没有唯一性约束,要进行 加锁 操作。如果是分布式环境,要用 分布式锁。
而使用唯一性约束的方式,可以大大减少开发时的工作量。
1. 创建题库题目关联:
- 已有的创建题库题目关联请求类
/**
* 创建题库题目关联请求
*
* @author BraumAce
*/
@Data
public class QuestionBankQuestionAddRequest implements Serializable {
/**
* 题库 id
*/
private Long questionBankId;
/**
* 题目 id
*/
private Long questionId;
private static final long serialVersionUID = 1L;
}
- 已生成的创建题库题目关联接口,只需要补充数据不存在的校验,修改 QuestionBankQuestionServiceImpl 的
validQuestionBankQuestion
方法即可。
/**
* 创建题库题目关联(仅管理员可用)
*
* @param questionBankQuestionAddRequest
* @param request
* @return
*/
@PostMapping("/add")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Long> addQuestionBankQuestion(@RequestBody QuestionBankQuestionAddRequest questionBankQuestionAddRequest, HttpServletRequest request) {
ThrowUtils.throwIf(questionBankQuestionAddRequest == null, ErrorCode.PARAMS_ERROR);
// todo 在此处将实体类和 DTO 进行转换
QuestionBankQuestion questionBankQuestion = new QuestionBankQuestion();
BeanUtils.copyProperties(questionBankQuestionAddRequest, questionBankQuestion);
// 数据校验
questionBankQuestionService.validQuestionBankQuestion(questionBankQuestion, true);
// todo 填充默认值
User loginUser = userService.getLoginUser(request);
questionBankQuestion.setUserId(loginUser.getId());
// 写入数据库
boolean result = questionBankQuestionService.save(questionBankQuestion);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
// 返回新写入的数据 id
long newQuestionBankQuestionId = questionBankQuestion.getId();
return ResultUtils.success(newQuestionBankQuestionId);
}
- 修改校验逻辑
/**
* 校验数据
*
* @param questionBankQuestion
* @param add 对创建的数据进行校验
*/
@Override
public void validQuestionBankQuestion(QuestionBankQuestion questionBankQuestion, boolean add) {
ThrowUtils.throwIf(questionBankQuestion == null, ErrorCode.PARAMS_ERROR);
// 参数校验,题库和题目必须存在
Long questionId = questionBankQuestion.getQuestionId();
if (questionId != null) {
Question question = questionService.getById(questionId);
ThrowUtils.throwIf(question == null, ErrorCode.NOT_FOUND_ERROR, "题目不存在");
}
Long questionBankId = questionBankQuestion.getQuestionBankId();
if (questionBankId != null) {
QuestionBank questionBank = questionBankService.getById(questionBankId);
ThrowUtils.throwIf(questionBank == null, ErrorCode.NOT_FOUND_ERROR, "题库不存在");
}
}
注意:QuestionService 和 QuestionBankQuestionService 存在互相引用,会导致循环依赖问题,让项目无法启动。
解决方法:在 QuestionBankQuestionServicelmpl 中引用 QuestionService 的位置加上 @Lazy
注解解决。
@Resource
@Lazy // 懒加载
private QuestionService questionService;
@Resource
private QuestionBankService questionBankService;
2. 移除题库题目关联:
- 增加题库题目关联请求类
/**
* 移除题库题目关联请求
*
* @author BraumAce
*/
@Data
public class QuestionBankQuestionRemoveRequest implements Serializable {
/**
* 题库 id
*/
private Long questionBankId;
/**
* 题目 id
*/
private Long questionId;
private static final long serialVersionUID = 1L;
}
- 编写移除题库题目关联接口
/**
* 移除题库题目关联
*
* @param questionBankQuestionRemoveRequest
* @return
*/
@PostMapping("/remove")
public BaseResponse<Boolean> removeQuestionBankQuestion(@RequestBody QuestionBankQuestionRemoveRequest questionBankQuestionRemoveRequest) {
// 参数校验
ThrowUtils.throwIf(questionBankQuestionRemoveRequest == null, ErrorCode.PARAMS_ERROR);
Long questionBankId = questionBankQuestionRemoveRequest.getQuestionBankId();
Long questionId = questionBankQuestionRemoveRequest.getQuestionId();
ThrowUtils.throwIf(questionBankId == null || questionId == null, ErrorCode.PARAMS_ERROR);
// 构造查询
LambdaQueryWrapper<QuestionBankQuestion> lambdaQueryWrapper = Wrappers.lambdaQuery(QuestionBankQuestion.class)
.eq(QuestionBankQuestion::getQuestionBankId, questionBankId)
.eq(QuestionBankQuestion::getQuestionId, questionId);
boolean result = questionBankQuestionService.remove(lambdaQueryWrapper);
return ResultUtils.success(result);
}
提示
一般 Service 层中讲究的是复用性,或者是复杂的业务逻辑。
如果 Controller 层中逻辑非常简单,也不需要复用,可以直接将实现写在 Controller 层中。
扩展
如果用同样的参数多次调用 “创建题库题目关联接口”,会因为数据库的唯一性约束导致
org.springframework.dao.DuplicateKeyException
异常,然后会被全局异常处理器处理返回 “系统错误”。针对这种情况可以进行异常捕获,并优化报错文案,比如 “不能重复加入”。如果给题库表增加题目总数字段,则修改题目所属题库时,要同时更新题库表的题目总数,涉及多表操作,需要使用事务实现。