CC's Boat

一顾青山舟远去,归来仍是顾青山

0%

瑞吉外卖项目

瑞吉外卖项目开发记录

1.写在最前

首先特别感谢黑马程序员提供的开源课程及项目https://www.bilibili.com/video/BV13a411q753

该项目的技术栈包括Spring, SpringMVC, MyBatis/MyBatis Plus, SpringBoot,MySQL,Redis, Maven, Git,适合作为SpringBoot框架学习的练手项目,感兴趣的朋友可以点击上述的链接进行学习。

博主也是刚学习完框架,写个项目练练手,会把项目中遇到了一些值得强调的点写在这里作为highlight。

2.环境搭建

首先建库建表,这块没什么好说的,黑马提供了sql脚本帮助建表,直接执行即可,注意执行过程中会发现utf8mb3_bin的字符集已经被废弃(https://dev.mysql.com/blog-archive/mysql-8-0-when-to-use-utf8mb3-over-utf8mb4/)所以可以更改相应的字符集设定,我这里偷懒没改。

然后在IDEA中创建相应的Maven工程,可以选择使用Maven模板创建或者使用Spring Initializar, 然后在pom中补充需要的依赖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<groupId>fun.xinliu</groupId>
<artifactId>tiny_takeaway</artifactId>
<version>0.0.1-SNAPSHOT</version>

<properties>
<java.version>8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.2</version>
</dependency>


<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.76</version>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.23</version>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>

</project>

新建完项目后首先在application.yml中进行属性配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
server:
port: 8088
spring:
datasource:
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/takeaway_db?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: cc417230
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: ASSIGN_ID

属性配置没有太多可以说明的地方,都是一些web项目常见的配置,端口号,数据源连接和对象关系映射,目前所需的配置就是这些,后续有需要时再继续补充。

因为我们专注在项目的后端部分,所以前端的静态资源就直接使用黑马提供给我们的即可。注意SpringBoot默认的静态资源Handler规定了静态资源的defualt路径

1
"**By default, this handler serves static content from any of the \*/static, /public, /resources,\* and \*/META-INF/resources\* directories that are on the classpath**. "

如果静态资源放置于规定的路径下那么无需更多的配置即可直接在项目的context下访问,否则的话需要在application.yml下进行配置或者创建一个配置类。在ym中配置的key如下:

1
2
3
4
5
6
spring:
mvc:
static-path-pattern:
web:
resources:
static-locations:

其实这就是原来我们在SSM中配置的defultServlet,用于在DispatcherServlet前对静态资源请求进行处理

1
<mvc:default-servlet-handler/>  

这里为了熟悉配置类的使用我们选择使用配置类的方式进行配置。新建一个WebMvcConfig类,继承WebMvcConfigurationSupport然后重写其addResourceHandlers方法即可。

1
2
3
4
5
6
7
8
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
@Override
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/front/**").addResourceLocations("classpath:/static/front/");
registry.addResourceHandler("/backend/**").addResourceLocations("classpath:/static/backend/");
}
}

准备工作及环境搭建大概就是如此,启动application然后访问8088端口,可以看到静态资源已经可以访问了。Backend

3.后台系统登录功能

3.1需求分析

因为登陆功能相对比较简单,所以我们从登陆功能开始入手,需求分析大致遵循如下的步骤

1.页面原型分析

2.登陆页面分析(分析前端页面和后端约定的数据传输格式)

3.查看登陆请求信息,如请求的pattern请求发送的参数balabala

loginPage

4.查看对应的数据模型

3.2代码开发

创建实体类和表进行映射

创建对应的Mapper和Service及其实现{.tabset}

EmployeeMapper

直接继承BaseMapper即可

1
2
3
@Mapper
public interface EmployeeMapper extends BaseMapper<Employee> {
}
EmployeeService

继承IService

1
2
public interface EmployeeService extends IService<Employee> {
}
EmployeeServiceImpl

继承EmployeeService并实现ServiceImpl,注意泛型中要填入mapper和实体类

1
2
3
@Service
public class EmployServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
}

统一结果封装

由于前端在接受和处理来自后端的数据时希望能有一个统一的数据格式,所以在返回数据前,我们应该对任何格式的数据进行统一封装处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Data
public class Result<T> {
private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
private Map map = new HashMap(); //动态数据

public static <T> Result<T> success(T object) {
Result<T> result = new Result<T>();
result.data = object;
result.code = 1;
return result;
}

public static <T> Result<T> error(String msg) {
Result result = new Result();
result.msg = msg;
result.code = 0;
return result;
}

public Result<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}

编写对应的Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Slf4j
// caution here, RestController 注解的value是用于为组件起一个逻辑名称
@RestController
@RequestMapping("/employee")
public class EmployeeController {

@Autowired
private EmployeeService employeeService;

/**
* create by: Xin Liu
* description: 这个控制器负责后台系统的登陆和登出功能
* create time: 2023/4/13 2:42 PM
*
* @param employee 封装前端页面请求传入的JSON数据
* @return fun.xinliu.entity.Employee
*/
@PostMapping("/login")
public Result<Employee> login(HttpServletRequest httpServletRequest, @RequestBody Employee employee) {

String username = employee.getUsername();
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());

LambdaQueryWrapper<Employee> lqw = new LambdaQueryWrapper<>();
lqw.eq(Employee::getUsername,username);
Employee emp = employeeService.getOne(lqw);

if(emp == null) {
return Result.error("该用户名或密码不正确");
}

if(!emp.getPassword().equals(password)){
return Result.error("该用户名或密码不正确");
}

if(emp.getStatus() == 0){
return Result.error("该账户已被禁用,请联系管理员");
}

// 为什么要记录登陆id?
// 为了后续校验当前访问请求是否已经登陆
httpServletRequest.getSession().setAttribute("employee", emp.getId());

return Result.success(emp);
}
}

登出功能

有登入就要有登出功能,登出功能相对较为简单,在EmployeeController中设置对应的pattern和相应的功能即可,注意登出时要清空当前会话域中保存的登陆Id

1
2
3
4
5
6
@PostMapping("/logout")
public Result<String> logout(HttpServletRequest request) {
// 清理session中保存的当前登陆的员工id
request.getSession().removeAttribute("employee");
return Result.success("退出成功");
}

至此我们就完成了后台管理系统登陆功能的基础开发,但是仔细回想一下,我们的登陆功能仍然有很大的缺陷,因为我们在访问后台的index.html页面时并没有对用户是否已经登陆进行校验, 合理的情况下,我们应该只允许成功登陆了的用户去访问我们的后台页面,并对来自未登陆用户的请求进行拦截,下面就让我们来完善这一功能。

完善登陆功能

登陆功能的完善可以通过过滤器和拦截器实现,这里我们使用过滤器,过滤器的使用需要新建一个过滤器类,并让其实现Filter接口。除此之外,我们还需要让Spring IOC容器将我们声明的过滤器类作为Bean纳入容器的管理之中,所以我们还需要使用@WebFilter该类,并在我们的SpringBootApplication上添加@ServletComponentScan注解。顾名思义,这个注解主要是通知SpringBoot开启对Servlet相关组件的扫描功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@WebFilter(filterName = "loginCheckFilter", urlPatterns = "/*")
@Slf4j
public class LoginCheck implements Filter {

public static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();

@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;

// 获取本次请求的URI
String requestURI = request.getRequestURI();

// 列出无需拦截的urls
String[] urls = new String[] {
"/employee/login", "/employee/logout", "/backend/**", "/front/**"
};

// 判断本次请求是否需要处理,如果符合exclude的pattern则直接放行
if(check(urls, requestURI)) {
filterChain.doFilter(request,response);
return;
}

// 否则判断是否已经登陆, 如果已经登陆则放行
if(request.getSession().getAttribute("employee") != null) {
filterChain.doFilter(request,response);
return;
}

// 如果未登陆,则写数据返回前端
// 注意这里写的数据需要和前端进行协作,要符合指定的数据交换规范
response.getWriter().write(JSON.toJSONString(Result.error("NOTLOGIN")));
}

// helper函数,负责进行校验
public boolean check(String[] urls, String requestURI) {
for (String s:urls) {
boolean match = PATH_MATCHER.match(s, requestURI);
if(match){
return true;
}
}
return false;
}
}

4. 添加员工

4.1 添加员工流程分析

通过查看原型页面和前端页面的代码我们可以发现,添加员工的主要流程如下:

1.前端页面发送ajax请求,将添加员工页面中输入的数据以JSON的格式提交到服务器

2.服务器端相应的handler方法接受数据调用业务层方法对数据进行保存

3.业务层方法调用数据访问层方法操作数据库保存数据

相应的数据模型,参数传递,调用函数和请求格式如下:

1
2
3
4
5
6
7
ruleForm : {
'name': '',
'phone': '',
'sex': '男',
'idNumber': '',
username: ''
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
submitForm (formName, st) {
this.$refs[formName].validate((valid) => {
if (valid) {
if (this.actionType === 'add') {
const params = {
...this.ruleForm,
sex: this.ruleForm.sex === '女' ? '0' : '1'
}
addEmployee(params).then(res => {
if (res.code === 1) {
this.$message.success('员工添加成功!')
if (!st) {
this.goBack()
} else {
this.ruleForm = {
username: '',
'name': '',
'phone': '',
// 'password': '',
// 'rePassword': '',/
'sex': '男',
'idNumber': ''
}
}
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
} else {
const params = {
...this.ruleForm,
sex: this.ruleForm.sex === '女' ? '0' : '1'
}
editEmployee(params).then(res => {
if (res.code === 1) {
this.$message.success('员工信息修改成功!')
this.goBack()
} else {
this.$message.error(res.msg || '操作失败')
}
}).catch(err => {
this.$message.error('请求出错了:' + err)
})
}
} else {
console.log('error submit!!')
return false
}
})
}
1
2
3
4
5
6
7
addEmployee (params) {
return $axios({
url: '/employee',
method: 'post',
data: { ...params }
})
}

4.2 后端处理

注意,后端handler方法接收到的JSON数据仅包含Employee中的部分字段,所以在添加到数据库之前我们还需要对其他字段进行填充处理,具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@PostMapping
public Result<String> save(HttpServletRequest httpServletRequest, @RequestBody Employee employee) {
log.info("新增员工: {}", employee.toString());

// 设置一个初始密码,需要使用md5进行加密
employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));

employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());

Long empId = (Long)httpServletRequest.getSession().getAttribute("employee");

employee.setUpdateUser(empId);
employee.setCreateUser(empId);

employeeService.save(employee);
return Result.success("新增员工成功");
}

此时我们的添加功能就大致做好了,不过还有一点需要注意的地方,那就是在数据表中,我们对用户的登陆id即username字段进行了唯一约束,如果我们重复添加具有相同username的员工数据会导致一个SQLIntegrityConstraintViolationException异常,异常的提示信息是”Duplicate entry ‘xxx’ for key ‘employee.idx_username’”,但是我们在前端页面只能看到500状态码对应的服务器内部错误,却得不到任何有关该错误的提示信息,这显然是不合理的。因此,我们有必要对这个可能抛出的异常进行处理。

4.3 统一全局异常处理

在JavaSE中我们学习了一些异常处理方式如try catch或者throw,但是一个项目中通常会有多处功能可能抛出不同类型的异常,如果我们都采用try catch的方式的就显得有些繁琐且不易于管理,因此在此处我们使用Spring框架提供的全局异常处理方式来统一进行异常处理。

首先我们在common包下新建一个GlobalExceptionHandler类,为其加上@ControllerAdvice注解,说明这是一个面向Controller的通知。Advice是AOP的核心概念,用于封装一个切面的所有属性,包括切入点和需要织入的切面逻辑。这里我们就使用这种机制来实现统一的异常管理。注意在ControllerAdvice注解的annotations属性中注明需要管理的注解类型。由于我们统一返回RESTful格式的结果所以注意添加@ResponseBody的注解(也可以使用复合注解@RestControllerAdvice)。

其次是创建相应的exceptionHandler方法,并使用@ExceptionHandler注明需要处理的异常类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@ControllerAdvice(annotations = {RestController.class, Controller.class})
@ResponseBody
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
public Result<String> exceptionHandler(SQLIntegrityConstraintViolationException ex) {
log.error(ex.getMessage());

if(ex.getMessage().contains("Duplicate entry")){
String[] strings = ex.getMessage().split(" ");
String msg = "用户名" + strings[2] + "已存在";
return Result.error(msg);
}

return Result.error("未知错误");
}
}

公共字段填充

问题分析

之前我们在新增员工和编辑员工时需要修改时间和修改人等字段,这些字段属于公共字段,很多表中都有这些字段,那么我们能不能在某个地方统一处理这些公共字段来简化开发呢?

答案是可以的,MyBatis Plus为我们提供了公共字段自动填充的功能,下面让我们来看看该怎么使用这一功能。

代码实现

公共字段填充功能的核心在于实现MetaObjectHandler这样一个元数据处理器,并且在需要自动填充的字段上使用@TableField注解,@TableField注解的fill值对应着触发自动字段填充的方法,比如Insert或者Insert&Update。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
v@Data
public class Employee implements Serializable {

//省略了其他字段
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;

@TableField(fill = FieldFill.INSERT)
private Long createUser;

@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}

其次,我们需要实现这样一个接口,在对应的方法中设定自动的数据填充,值得一提的是,因为在MetaObjectHandler的实现类中我们没有办法获取当前登陆的Session,继而无法直接获得当前Session中存储的登陆者的id,所以这里我们需要经由ThreadLocal来代为实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@Slf4j
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
log.info("公共字段填充(create)...");
metaObject.setValue("createTime", LocalDateTime.now());
metaObject.setValue("updateTime", LocalDateTime.now());
//设置创建人id
metaObject.setValue("createUser", BaseContext.getCurrentId());
metaObject.setValue("updateUser", BaseContext.getCurrentId());
}

@Override
public void updateFill(MetaObject metaObject) {
log.info("公共字段填充(insert)...");
metaObject.setValue("updateTime", LocalDateTime.now());
//设置更新人id
metaObject.setValue("updateUser", BaseContext.getCurrentId());
}
}

已知客户端发送的每次http请求,对应的在服务端都会分配一个新的线程来处理,通过实验我们可以发现在处理过程中下面这些类中的方法都属于相同的一个线程:

  1. LocalCheekFilter中的doFilter方法
  2. EmployeeController中的update方法
  3. MyMetaObjectHandler中的updateFill方法

所以我们可以在doFilter方法中将登陆者的id存入ThreadLocal中,然后在后续的元数据处理器中进行填充,出于复用性的考虑,我们将这个过程抽取出来并封装到一个工具类中实现,具体的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* create by: Xin Liu
* description: 基于ThreadLocal的封装工具类,用于保存和获取当前登陆用户的id
* create time: 2023/4/14 4:00 PM
*/
public class BaseContext {

private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

public static void setCurrentId(Long id) {
threadLocal.set(id);
}

public static Long getCurrentId() {
return threadLocal.get();
}
}