某马瑞吉外卖单体架构项目完整开发文档,基于 Spring Boot 2.7.11 + JDK 11。预计 5 月 20 日前更新完成,有需要的胖友记得一键三连,关注主页 “瑞吉外卖” 专栏获取最新文章。
相关资料:https://pan.baidu.com/s/1rO1Vytcp67mcw-PDe_7uIg?pwd=x548
提取码:x548
文章目录
- 1.需求分析
- 2.代码开发
- 2.1 创建实体类
- 2.2 创建基本项目结构
- 2.3 编写 Mapper 接口
- 2.4 Service 层
- 2.5 Controller 层
- 3.功能测试
- 4.分析后台系统首页构成
1.需求分析
产品原型如下:
交互说明:
- 使用用户名和密码登录(用户名、密码不能为空),若为空提示“不能为空”;
- 成功登陆,进入系统;
- 登陆失败,提示语 3 秒“输入有误请重试,还剩 4 次!”;
- 限制输入 1-20 个字符。
项目中对应的页面路径:static/backend/page/login/login.html
由于上一篇文章中,我们已经设置了静态资源路径的映射,因此我们可以直接访问具体的登陆页面。启动程序,浏览器访问 http://localhost:8080/static/backend/page/login/login.html:
上面的 admin 用户我们在导数数据表的时候,在 employee 表中已经默认注册了一个员工:
其中的用户就是 admin,密码是经过加密后的字符串。我们可以通过浏览器打开调试工具(F12),查看点击登陆后的一些基本请求和响应信息:
可以发现,当我们点击登陆按钮时,页面会发送一个 POST 请求,请求路径为 “http://localhost:8080/employee/login” ,并且以 JSON 格式提交了请求参数 username 和 password,其中的 123456 就是数据库表中的密码解密后的明文形式。
该请求返回的状态码为 404,因为我们还没有在服务端处理此请求,所以我们需要做的第一件事就是创建相关的 controller 类来处理该请求,并按预期的方式进行响应。
下面我们通过 static/backend/page/login/login.html 源码进行进一步的分析,关键部分源码如下:
简单分析一下,首先我们发送了一个请求,点进 loginApi()
方法,涉及到的源码如下:
function loginApi(data) {
// 通过axios发送请求
return $axios({
// 请求地址
url: "/employee/login",
// 请求方式
method: "post",
// 请求参数
data,
});
}
可以看到当我们点击登录按钮后,会通过 axios 发送一个 POST 请求,请求的路径便是 “/employee/login”。接着看上面截图圈出的其他部分知道,需要从响应对象中获取到几个基本信息:
- code 用于表示是否登陆成功,约定 1 为登陆成功;
- data 为用户信息;
- 登陆成功后应该跳转到首页 “/backend/index.html”;
- 登陆失败则返回失败信息 msg。
OK,基于上述需求分析,我们就可以正式进行功能的实现了。
2.代码开发
2.1 创建实体类
首先第一步肯定就是创建一个实体类 Employee 用于和数据表 employee 进行 ORM 映射。
在下载的资料的 “瑞吉外卖\瑞吉外卖项目\资料\实体类”目录下已经准备好了需要用到的各个实体类。我们首先在项目中创建一个 entity
包用于存放项目所需的实体类,然后将资料中的实体类一次性全部复制进去即可。
Employee 实体类完整代码如下:
package cn.javgo.reggie_take_out.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
@Data
public class Employee implements Serializable {
// 用于序列化的版本号
private static final long serialVersionUID = 1L;
// 自增主键
private Long id;
// 用户名
private String username;
// 姓名
private String name;
// 密码
private String password;
// 手机号
private String phone;
// 性别
private String sex;
// 身份证号
private String idNumber;
// 状态,0:禁用,1:启用
private Integer status;
// 创建时间
private LocalDateTime createTime;
// 更新时间
private LocalDateTime updateTime;
// 创建人
@TableField(fill = FieldFill.INSERT)
private Long createUser;
// 更新人
@TableField(fill = FieldFill.INSERT_UPDATE)
private Long updateUser;
}
2.2 创建基本项目结构
再开始编写代码前,我们需要先创建好对应的包:
- controller:控制层
- mapper:持久层(这里有与使用 MyBatis Plus,故而将 dao 换成了 mapper)
- service:业务逻辑层
最终的项目结构应该如下:
2.3 编写 Mapper 接口
使用了 MyBatis Plus 之后只需要将 Mapper 接口继承 MyBatis Plus 提供的 BaseMapper<T>
接口即可。
下面是 EmployeeMapper
接口完整代码:
package cn.javgo.reggie_take_out.mapper;
import cn.javgo.reggie_take_out.entity.Employee;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface EmployeeMapper extends BaseMapper<Employee>{
}
2.4 Service 层
同样,MyBatis Plus 提供了一个 IService<T>
接口,我们可以直接将 EmployeeService
接口继承它即可。
下面是 EmployeeService
接口完整代码:
package cn.javgo.reggie_take_out.service;
import cn.javgo.reggie_take_out.entity.Employee;
import com.baomidou.mybatisplus.extension.service.IService;
public interface EmployeeService extends IService<Employee> {
}
对应的我们需要准备一个实现类,MyBatis Plus 同样提供了一个IService<T>
接口的实现 ServiceImpl<M extends BaseMapper<T>, T>
,因此直接用 EmployeeServiceImpl
实现类继承该实现类即可。
下面是 EmployeeServiceImpl
实现类完整代码:
package cn.javgo.reggie_take_out.service.impl;
import cn.javgo.reggie_take_out.entity.Employee;
import cn.javgo.reggie_take_out.mapper.EmployeeMapper;
import cn.javgo.reggie_take_out.service.EmployeeService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
@Service
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
}
2.5 Controller 层
在正式开始之前,我们需要准备一个返回结果类 R<T>
,该类是一个通用类,服务端响应的所有结果最终都会包装成此类型返回给前端。
该类同样已经提供有,我们直接将其复制到项目的 cn.javgo.reggie_take_out.common
包下即可,其中的 common
包需要自行创建,用于存放各种通用类。
文件路径:瑞吉外卖\瑞吉外卖项目\资料\服务端返回结果类\R.java
该结果类完整代码如下:
package cn.javgo.reggie_take_out.common;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
/**
* 通用返回结果类,服务端响应的数据最终都会封装成该类的对象
*/
@Data
public class R<T> {
// 编码:1成功,0和其它数字为失败
private Integer code;
// 错误信息
private String msg;
// 数据
private T data;
// 动态数据
private Map<String,Object> map = new HashMap<>();
/**
* 响应成功时调用,用于将数据封装到响应对象中,并返回状态码为1表示成功
* @param object 响应数据
* @return 响应对象
* @param <T> 响应数据类型
*/
public static <T> R<T> success(T object) {
R<T> r = new R<>();
r.data = object;
r.code = 1;
return r;
}
/**
* 响应失败时调用,用于将错误信息封装到响应对象中,并返回状态码为0表示失败
* @param msg 错误信息
* @return 响应对象
* @param <T> 响应数据类型
*/
public static <T> R<T> error(String msg) {
R<T> r = new R<>();
r.msg = msg;
r.code = 0;
return r;
}
/**
* 用于向响应对象中添加动态数据
* @param key 键
* @param value 值
* @return 响应对象
*/
public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}
下面开始正式编写控制层的相关代码,我们准备一个 EmployeeController
类在其中编写登陆方法。登陆方法的处理逻辑大致如下:
- 将页面提交的密码 password 进行 md5 加密处理;
- 根据页面提交的用户名 username 查询数据库;
- 如果没有查询到,则返回登陆失败结果;
- 进行密码比对,如果不一致则返回登陆失败结果;
- 查看员工状态,如果为已禁用状态,则返回员工已禁用结果;
- 登陆成功,将员工 id 存入 Session 并返回登陆成功的结果。
流程图如下:
EmployeeController
类的登陆部分代码如下:
package cn.javgo.reggie_take_out.controller;
import cn.javgo.reggie_take_out.common.R;
import cn.javgo.reggie_take_out.entity.Employee;
import cn.javgo.reggie_take_out.service.EmployeeService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.DigestUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
@Slf4j
@RestController
@RequestMapping("/employee")
public class EmployeeController {
@Resource
private EmployeeService employeeService;
/**
* 处理登陆请求
* 说明:
* 1.由于前端携带的用户名和密码是以 JSON 形式提交的,所以需要使用 @RequestBody 注解将请求体中的 JSON 数据转换为 Employee 对象
* 2.由于前端传过来的是明文密码,所以需要对密码进行 MD5 加密处理
* 3.需要使用 HttpServletRequest 对象获取 session 对象,用于将登陆成功的用户信息存入 session 中,以便后续的请求可以直接获取到用户信息
*
* @param request 请求对象
* @param employee 员工对象
* @return 响应对象
*/
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
// 1.将页面提交的密码进行 MD5 加密处理
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());
// 2.根据用户名查询数据库
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Employee::getUsername, employee.getUsername());
Employee emp = employeeService.getOne(queryWrapper);
// 3.如果没有查询到则返回登录失败结果
if (emp == null) {
return R.error("登陆失败");
}
// 4.进行密码比对,如果不一致则返回登录失败结果
if (!emp.getPassword().equals(password)) {
return R.error("登陆失败");
}
// 5.查看员工状态,如果为已禁用状态,则返回员工已禁用结果
if (emp.getStatus() == 0) {
return R.error("账号已禁用");
}
// 6.登录成功,将员工 id 存入 Session 并返回登录成功结果
request.getSession().setAttribute("employee", emp.getId());
return R.success(emp);
}
}
上述在使用 MyBatis Plus 进行查询时涉及到了一些相关知识,简单分析如下:
LambdaQueryWrapper<T>
:LambdaQueryWrapper 是 MyBatis Plus 提供的一个查询条件构造器,用于在查询时使用 Lambda 表达式来构建查询条件。它是一个泛型类,其中的泛型 T 用于指定查询的实体类型。并且调用了 LambdaQueryWrapper 的eq()
方法,用于添加等于条件。即指定查询条件为:Employee 实体的用户名字段等于给定的用户名。T getOne(Wrapper<T> queryWrapper)
:getOne() 是 MyBatis Plus 实现的方法,用于查询满足条件的单个实体。根据传入的 LambdaQueryWrapper 对象,它将根据指定的查询条件从数据库中获取符合条件的实体。
值得注意的是,上面的写法如果在我们的项目中运行便会报错,报错信息如下:
ERROR 21812 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.builder.BuilderException: Error evaluating expression 'ew.sqlSegment != null and ew.sqlSegment != '' and ew.nonEmptyOfWhere'. Cause: org.apache.ibatis.ognl.OgnlException: sqlSegment [java.lang.ExceptionInInitializerError]] with root cause
java.lang.reflect.InaccessibleObjectException: Unable to make field private final java.lang.Class java.lang.invoke.SerializedLambda.capturingClass accessible: module java.base does not "opens java.lang.invoke" to unnamed module @68999068
这个错误和 Java 的模块化系统(Jigsaw),尤其是 Java 9 及之后版本引入的强封装性有关。这个错误的原因是我们用了 JDK 8 引入的 Lambda 表达式和函数引用,这在 MyBatis Plus 中被广泛用于构建动态 SQL 查询,其实也就是因为使用了 LambdaQueryWrapper。然而,MyBatis Plus 在通过 LambdaQueryWrapper 执行这些操作时需要通过反射访问 java.lang.invoke.SerializedLambda
类的一些字段,但是由于 Java 的模块化系统的安全限制,这些字段并不能被反射访问。
最简单的解决办法就是将我们的 JDK 版本降低到 JDK 1.8,但是笔者这里选择继续使用 JDK 11,那就不得不放弃使用 LambdaQueryWrapper,而是使用更传统的方式来构建查询,但这可能会失去一些 MyBatis Plus 提供的便利性。
例如,我们可以通过 MyBatis Plus 的 QueryWrapper,而不是 LambdaQueryWrapper 来进行条件构建:
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
// 1.将页面提交的密码进行 MD5 加密处理
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());
// 2.根据用户名查询数据库
/*LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Employee::getUsername,employee.getUsername());
Employee emp = employeeService.getOne(queryWrapper);*/
QueryWrapper<Employee> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("username", employee.getUsername());
Employee emp = employeeService.getOne(queryWrapper);
// 3.如果没有查询到则返回登录失败结果
if (emp == null) {
return R.error("登陆失败");
}
// 4.进行密码比对,如果不一致则返回登录失败结果
if (!emp.getPassword().equals(password)) {
return R.error("登陆失败");
}
// 5.查看员工状态,如果为已禁用状态,则返回员工已禁用结果
if (emp.getStatus() == 0) {
return R.error("账号已禁用");
}
// 6.登录成功,将员工 id 存入 Session 并返回登录成功结果
request.getSession().setAttribute("employee", emp.getId());
return R.success(emp);
}
可以看到,我们需要手动提供数据库中的列名 “username”,而不是使用函数引用 Employee::getUsername
。虽然这样可以避免反射访问 java.lang.invoke.SerializedLambda
,但这样的代码可能会更难维护,因为它不再具有类型安全性,并且如果数据库的列名改变了,我们也需要手动修改代码。
为了避免上面的问题,还有一个解决方案就是使用 JVM 参数来开放
java.lang.invoke
模块:--add-opens java.base/java.lang.invoke=ALL-UNNAMED
这会允许所有未命名的模块访问
java.lang.invoke
。
3.功能测试
下面启动应用,再次访问 http://localhost:8080/static/backend/page/login/login.html 进行登陆测试,账户密码分别为 admin 和 12346。
首先使用一个错误的密码进行测试,结果如下:
可以看到响应失败了,我们可以在密码比对处打上断点,就能明显看到是因为密码比对失败:
换成正确的用户名和密码就能正确进入后台管理首页,但遗憾的是页面访问失败:
从 URL 可以看出登陆成功后跳转到了 “/backend/index.html”这是因为当时瑞吉外卖的项目静态文件是直接放在资源目录 resources 下的,而我们是放在 resources/static 目录下,这样更加符合规范。所以只需要对应修改 static/backend/page/login/login.html 中的登陆成功后的跳转页面为 /static/backend/index.html 即可。
重启应用再次测试就能成功跳转到主页了:
在,后端代码中我们返回了 R.success(emp)
,传入的参数就是对象信息,最终会被赋值给 R 类中的 data 属性,前端接收到响应对象后会将用户信息转为 JSON 数据存储在浏览器,关键代码见下图:
那么存储的信息在哪儿呢?我们可以通过浏览器打开调试工具(F12),在 Application 页面的 Storage 页面中找到:
4.分析后台系统首页构成
下面简单分析一些会面会用到的前端页面的关键部分,有利于后期开发。
其中的左侧菜单项被定义在 static/backend/index.html 中的如下位置:
当我们点击菜单栏的时候又是如何进行对应页面的切换呢?
可以看到,当点击左侧对应的列表项时,会触发一个 menuHandle(item,false)
函数,并传入了对应的 item 对象。追踪到下面的 menuHandle()
函数:
从上图中圈出部分可以看到,通过 item 对象获得了对应的 url 信息,然后通过 iframeUrl 的方式显示了一个新的页面。追踪到 iframeUrl 位置处:
可见,其实就是使用 item.url 对应的信息赋值给了默认的 “page/member/list.html”也就是我们看到的默认的员工页面,那 iframeUrl 是在何处被具体用到呢?接着向上追踪到如下位置:
不难看出,其实就是在页面的空白处创建了一个 iframe,具体的内容就是由 iframeUrl 对应请求传回的数据。
那么很容易就能想到,如果我们将 iframeUrl 的默认值进行修改,那么对应的就会出现我们想要的内容。下面进行一个简单测试,将 iframeUrl 的默认值修改为 “https://www.javgo.cn”,然后刷新网页对应就会出现该地址的页面:
注意:上述只是为了测试,笔者将地址修改为了个人博客地址,胖友测试完记得修改为原来的默认页面 page/member/list.html 哈。