瑞吉外卖
感谢黑马各位老师的馈赠
瑞吉外卖
项目前置部分
- 创建maven项目(创建结束后注意查看下项目对应的maven以及jre,jdk部分)
- 导入pom坐标,首先parent部分,继承springboot,之后是一个项目对应的依赖
- sql文件导入,图形化命令行随意
- 对应前端资源映射配置
前端资源配置
目的:修改资源映射路径
步骤:
- 创建WebMvcConfig配置类
- 继承WebMvcConfigurationSupport类并实现对应方法设置映射路径
1 |
|
前端对应login.html
js中的方法,这是需要提前约定好的,后端传入前端的数据:(分别是三个数据code,data,msg)
后台登录功能开发
首先创建大体的框架(三层,基于MP的规范,entity,service,mapper,controller)
注意: 在启动类中需要对mapper进行扫描,使用MapperScan注解写入mapper包的路径,之后对接口可以实现自动装配
结构大体如下:
细节部分 Mapper层添加Mapper注解,在实现类Impl层上添加Service注解且继承Service类,泛型为Mapper以及实体类,且实现接口,对应Controller层需要添加对应RestController注解以及类上的url路径,且需要对service实现自动装配
导入通用返回结果类
这里是服务端相应的数据返回信息,均封装成此对象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/**
* 通用返回结果类
* @param <T>
*/
public class R<T> {
private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
private Map map = new HashMap(); //动态数据
public static <T> R<T> success(T object) {
R<T> r = new R<T>();
r.data = object;
r.code = 1;
return r;
}
public static <T> R<T> error(String msg) {
R r = new R();
r.msg = msg;
r.code = 0;
return r;
}
public R<T> add(String key, Object value) {
this.map.put(key, value);
return this;
}
}
后台登录功能开发
开发流程
处理逻辑如下:
- 将页面提交的密码password进行md5加密处理
- 根据页面提交的用户名username查询数据库
- 如果没有查询到则返回登录失败结果
- 密码比对,如果不一致则返回登录失败结果
- 查看员工状态,如果为已禁用状态,则返回员工已禁用结果
- 登录成功,将员工id存入Session并返回登录成功结果
流程图:
一个小插曲
条件构造器
补充一个在学习MP中丢失的知识点:Wrapper
条件构造器中链式结构默认使用and,如果是或者就需要手动添加or()
Wrapper框架:
- 条件构造器组件排序条件:
- 删除条件:
- 修改功能:
这里update中两个参数,前者是设置修改的字段以及修改的值,后者是设置修改的对象 - 条件优先级的设定
consumer消费者接口中是一定要有参数的,而其中的方法就是对参数的操作
这里的param就是lambda中的条件构造器
在and方法和or方法中均有 - 组装select字句(查询其中某些字段)
updateWrapper
实现修改功能:(较queryWrapper较简单,无需创建实体对象)
补充结束,此时已经明白了什么是Wrapper条件构造,我们继续往下实现项目
后台登录功能实现:
- 登录功能
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
public class EmployeeServiceImpl extends ServiceImpl<EmployeeMapper, Employee> implements EmployeeService {
public R<Employee> login(HttpServletRequest request, Employee employee) {
// 1. 将页面提交的密码password进行md5加密处理
String password = employee.getPassword();
password = DigestUtils.md5DigestAsHex(password.getBytes());
// 2. 根据页面提交的用户名username查询数据库
LambdaQueryWrapper<Employee> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Employee::getUsername,employee.getUsername());
Employee emp = getOne(queryWrapper);
//3. 如果没有查询到则返回登录失败结果
if(emp==null){
return R.error("不存在该账户!");
}
//4. 密码比对,如果不一致则返回登录失败结果
if(emp.getPassword() != 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);
}
} - 退出功能退出的业务逻辑比较简单为了方便,我直接写在了controller层,建议还是放在service层
1
2
3
4
5
public R<String> logout(HttpServletRequest request){
request.getSession().removeAttribute("employee");
return R.success("退出成功!");
}
后台首页构成以及展示方式
这里设置对应的是首页右侧的菜单
为什么此处点击即可切换呢?
这个方法每次调用会切换item元素对应的url并展示在页面
完善登录功能
产生问题:这里使用index.html可以直接进入内置页面,不用登录,这是不合理的
业务修改:(必须登录才能进入,未登录将跳转到登录页面)
那么,具体应该怎么实现呢?
答案就是使用过滤器或者拦截器,在过滤器或者拦截器中判断用户是否已经完成登录,如果没有登录则跳转到登录页面。1
2
3
4
5
6
7
8
9
10
11
12
13
14/**
* 用户访问页面的拦截器,查看是否登录成功
*/
public class LoginCheckFilter implements Filter {
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
log.info("拦截到的信息:{}",request.getRequestURL());
filterChain.doFilter(request,response);
}
}
这里是检查业务,还未实现真正的拦截业务逻辑
还有一处需要注意,就是在启动类上需要添加一个注解@ServletComponentScan
,才能使过滤器被扫描到
代码开发
具体处理逻辑:
- 获取本次请求的URI
- 判断本次请求是否需要处理
- 如果不需要处理,则直接放行
- 判断登录状态,如果已登录,则直接放行
- 如果未登录则返回未登录结果,通过输出流方式先客户端(前端)页面响应数据,前端有做拦截器,其中返回的msg需要是NOTLOGIN
流程图:
代码实现: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/**
* 用户访问页面的拦截器,查看是否登录成功
*/
public class LoginCheckFilter implements Filter {
private static final AntPathMatcher PATH_MATCHER = new AntPathMatcher();
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 1. 获取本次请求的URI
String requestURI = request.getRequestURI();
String[] urls = {
"/employee/login",
"/employee/logout",
"/backend/**",
"/front/**"
};
log.info("本次拦截{}请求",requestURI);
// 2. 判断本次请求是否需要处理
boolean check = checkUrl(urls, requestURI);
// 3. 如果不需要处理,则直接放行
if(check){
log.info("本次请求为{}无需被拦截",requestURI);
filterChain.doFilter(request,response);
return;
}
// 4. 判断登录状态,如果已登录,则直接放行
if(request.getSession().getAttribute("employee") != null){
log.info("用户已登录,用户id为{}",request.getSession().getAttribute("employee"));
filterChain.doFilter(request,response);
return;
}
// 5. 如果未登录则返回未登录结果,通过输出流方式先客户端(前端)页面响应数据,前端有单独做拦截器,其中返回的msg需要是NOTLOGIN
log.info("用户未登录!");
response.getWriter().write(JSON.toJSONString(R.error("NOTLOGIN")));
return;
}
/**
* 判断页面是否可以通过过滤器
* @param urls
* @param requestURI
* @return
*/
private boolean checkUrl(String[] urls, String requestURI) {
for (String url : urls) {
boolean match = PATH_MATCHER.match(url, requestURI);
if (match) {
return true;
}
}
return false;
}
}
新增员工功能
小插曲,在学习前在此处复习一下之前学过的注解知识
- get和post
GET可以拥有请求体,RFC 文档中从来就没有说过 GET 没有请求体,RFC 只是说GET 意味着通过 URI 来识别资源。所以GET请求体中的数据一般都是不做处理的,有些 http 的 lib 里不让甚至直接不提供 GET 方法追加请求体的操作。
POST请求拥有请求体,并且请求数据一般都是放在请求体当中的。所以在处理POST请求时,通常都是从请求体中获取数据。
- RequestBody注解
- 用于接收前端发送的json数据(处理json格式数据)前端POST过来的数据会通过反序列数据到实体类中,并且在反序列的过程中会进行类型的转换
- RequestBody接收的是json格式的数据,只有请求体中能保存json,所以使用@RequestBody接收数据的时候必须是POST方式等方式。
- 可以和其它注解同时使用,但是只能使用一次
- RequestParam注解
可以用于接收URL中的参数并捆绑到方法的参数中,也可以接受post请求体中的Content-Type 为 application/x-www-form-urlencoded的数据。(post比较常用的是json格式数据)
用句简单的话说就是使用了这个注解就是添加了一些条件,前端要进来的时候必须要有这个条件规定的东西,如果没有就进不来
二者可以混合使用
业务开发:(后台这里只需要给对应数据附上初始值即可)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public R<String> save(Employee employee, HttpServletRequest request) {
// 给员工设置初始密码,同时进行md5加密
employee.setPassword(DigestUtils.md5DigestAsHex("123456".getBytes()));
// 给员工添加创建时间
employee.setCreateTime(LocalDateTime.now());
employee.setUpdateTime(LocalDateTime.now());
// 获得当前用户的id
// session中的数据取出来一般都是object类型,需要做强转
Long userId = (Long) request.getSession().getAttribute("employee");
employee.setCreateUser(userId);
employee.setUpdateUser(userId);
// 保存员工
save(employee);
return R.success("员工添加成功!");
}
看到这里有条sql语句insert执行成功即可。
但是前面的程序还存在一个问题,就是当我们在新增员工时输入的账号已经存在,由于employee表中对该字段加入了唯一约束,此时程序会抛出异常:
此时需要我们的程序进行异常捕获,通常有两种处理方式:
- 在Controller方法中加入try、catch进行异常捕获
- 使用异常处理器进行全局异常捕获
第一种不建议,如果使用try,catch的方式,同样的代码要写很多遍,冗余成分过多,所以此处使用第二种方式
代码实现类:GlobalExceptionHandler
在common包中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/**
* 全局异常捕获器
*/
public class GlobalExceptionHandler {
// 关键是此处的注解
public R<String> exceptionHandle(SQLIntegrityConstraintViolationException exception){
String message = exception.getMessage();
log.info(message);
if(message.contains("Duplicate entry")){
String[] strs = message.split(" ");
String user = strs[2];
String msg = "用户"+ user + "已存在!";
return R.error("用户{}已存在!");
}
return R.error("错误类型未定义");
}
}
小知识点:
@ControllerAdvice注解:扫描所有 annotations 中所设置的含有这个注解的类
只有设置annotations参数后才会生效。
@ExceptionHandler注解:括号内参数为异常的类型
小结
一般业务的实现基本都是这个流程,只不过是数据格式,请求地址等不同,基本上都是请求响应这样的模式。
员工信息分页查询
对需求进行分析
系统中的员工很多的时候,如果在一个页面中全部展示出来会显得比较乱,不便于查看,所以一般的系统中都会以分页的方式来展示列表数据。
右上角可以设置过滤条件,也是需要实现需求之一。
业务开发
在开发代码之前,需要梳理一下整个程序的执行过程:
- 页面发送ajax请求,将分页查询参数(page、pageSize、name)提交到服务端
- 服务端Controller接收页面提交的数据并调用Service查询数据
- Service调用Mapper操作数据库,查询分页数据
- Controller将查询到的分页数据响应给页面
- 页面接收到分页数据并通过ElementUl的Table组件展示到页面上
补充 :MP的分页插件配置
这里可以把Mapper的包扫描放置在此处
为什么是Interceptor?
因为这个配置器是做出条件限制拦截之后才会出现1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 配置MP分页插件
*/
public class MybatisPlusConfig {
public MybatisPlusInterceptor mybatisPlusInterceptor(){
MybatisPlusInterceptor mybatisPlusInterceptor = new MybatisPlusInterceptor();
mybatisPlusInterceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return mybatisPlusInterceptor;
}
}
业务流程:
- 因为使用MP分页插件,首先创建分页构造器
- 因为需要查询,所以这里再创建一个条件构造器
- 执行查询并返回
1 |
|
实现员工禁用/启用处理
需求分析
在员工管理列表页面,可以对某个员工账号进行启用或者禁用操作。账号禁用的员工不能登录系统,启用后的员工可以正常登录。
需要注意,只有管理员(admin用户)可以对其他普通用户进行启用、禁用操作,所以普通用户登录系统后启用、禁用按钮不显示。
禁用启用只有管理员才能看到。
开发
这里前端已经做了对应的校验,只有admin用户登录才可以显示禁用启用按钮。
那么这个是如何在前端实现的呢?
首先是此处方法获得当前用户的模型数据
上面在显示之前对当前模型进行了判断
业务开发:
controller层1
2
3
4
5
6
7
8
9
10/**
* 根据员工id修改员工状态
* @param request
* @param employee
* @return
*/
public R<String> update(HttpServletRequest request,{ Employee employee)
return employeeService.update(request,employee);
}
service层1
2
3
4
5
6
7
8
9
10
public R<String> update(HttpServletRequest request, Employee employee) {
// 获取当前用户
Long userId = (Long) request.getSession().getAttribute("employee");
// 设置对应修改时间以及用户
employee.setUpdateUser(userId);
employee.setUpdateTime(LocalDateTime.now());
updateById(employee);
return R.success("修改用户状态成功!");
}
功能测试中出现的问题:
原因:js获取Long型数据处理时丢失了精度,超过16位的一般会对剩余位数据进行四舍五入
如何解决?
我们可以在服务端给页面响应json数据时进行处理,将long型数据统一转为String字符串,效果如下:
如何转换? 创建一个消息转换器并修改mvc框架中的首个转换器为自己设定的
JacksonObjectMapper类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/**
* 对象映射器:基于jackson将Java对象转为json,或者将json转为Java对象
* 将JSON解析为Java对象的过程称为 [从JSON反序列化Java对象]
* 从Java对象生成JSON的过程称为 [序列化Java对象到JSON]
*/
public class JacksonObjectMapper extends ObjectMapper {
public static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public static final String DEFAULT_DATE_TIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
public static final String DEFAULT_TIME_FORMAT = "HH:mm:ss";
public JacksonObjectMapper() {
super();
//收到未知属性时不报异常
this.configure(FAIL_ON_UNKNOWN_PROPERTIES, false);
//反序列化时,属性不存在的兼容处理
this.getDeserializationConfig().withoutFeatures(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
SimpleModule simpleModule = new SimpleModule()
.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)))
.addSerializer(BigInteger.class, ToStringSerializer.instance)
.addSerializer(Long.class, ToStringSerializer.instance)
.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_TIME_FORMAT)))
.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT)))
.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern(DEFAULT_TIME_FORMAT)));
//注册功能模块 例如,可以添加自定义序列化器和反序列化器
this.registerModule(simpleModule);
}
}
同时在mvc配置类中修改
ps. 注意类不要写错了1
2
3
4
5
6
7
8
9
10
11
12/**
* 扩展添加自己设置创建的消息转换器
* @param converters
*/
protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
//创建消息转换器对象
MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
messageConverter.setObjectMapper(new JacksonObjectMapper());
// 将上面设置的消息转换器添加在mvc转换器狂框架中的前列
converters.add(0,messageConverter);
}
最后测试
发现禁用成功变成启用,通过
编辑员工信息
需求分析:
在员工管理列表页面点击编辑按钮,跳转到编辑页面,在编辑页面回显员工信息并进行修改,最后点击保存按钮完成编辑操作
代码开发:
在开发代码之前需要梳理一下操作过程和对应的程序的执行流程:
1、点击编辑按钮时,页面跳转到add.html,并在url中携带参数[员工id]
2、在add.html页面获取urt中的参数[员工id]
3、发送ajax请求,请求服务端,同时提交员工id参数
4、服务端接收请求,根据员工id查询员工信息,将员工信息以json形式响应给页面
5、页面接收服务端响应的json数据,通过VUE的数据绑定进行员工信息回显
6、点击保存按钮,发送ajax请求,将页面中的员工信息以json方式提交给服务端
7、服务端接收员工信息,并进行处理,完成后给页面响应
8、页面接收到服务端响应信息后进行相应处理
1 | /** |
分类管理菜单
相关业务目录如下:
字段自动填充
需求分析
前面我们已经完成了后台系统的员工管理功能开发,在新增员工时需要设置创建时间、创建人、修改时间、修改人等字段,在编辑员工时需要设置修改时间和修改人等字段。这些字段属于公共字段,也就是很多表中都有这些字段,如下:
能不能对于这些公共字段在某个地方统一处理,来简化开发呢?
Mybatis Plus就提供了公共字段自动填充功能
代码开发
如何实现?
Mybatis Plus公共字段自动填充,也就是在插入或者更新的时候为指定字段赋予指定的值,使用它的好处就是可以统一对这些字段进行处理,避免了重复代码。
实现步骤:
- 在实体类的属性上加入@FableField注解,指定自动填充的策略
- 按照框架要求编写元数据对象处理器,在此类中统一为公共字段赋值,此类需要实现MetaObjectHandler接口
实现如下:这里是存在一个相应的问题,就是对应的userId无法中动态写入只能写死,这里是很不合理的,那么这个问题该如何去解决呢?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/**
* 自定义元数据对象处理器
*/
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* 插入时对公共字段进行填充
* @param metaObject
*/
public void insertFill(MetaObject metaObject) {
log.info("在插入时填写公共字段...");
log.info(metaObject.toString());
metaObject.setValue("createTime", LocalDateTime.now());
metaObject.setValue("updateTime",LocalDateTime.now());
metaObject.setValue("createUser",new Long(1));
metaObject.setValue("updateUser",new Long(1));
}
/**
* 更新时对公共字段进行填充
* @param metaObject
*/
public void updateFill(MetaObject metaObject) {
log.info("在更新时填写更新字段...");
log.info(metaObject.toString());
metaObject.setValue("updateTime",LocalDateTime.now());
metaObject.setValue("updateUser",new Long(1));
}
}
答案是:使用threadlocal.
在学习ThreadLocal之前,我们需要先确认一个事情,就是客户端发送的每次http请求,对应的在服务端都会分配一个新的线程来处理,在处理过程中涉及到下面类中的方法都属于相同的一个线程:
1、LoginCheckFilter的doFilter方法
2、EmployeeController的update方法
3、MyMetaObjectHandler的updateFill
方法可以在上面的三个方法中分别加入下面代码(获取当前线程id):
test
什么是ThreadLocal?
ThreadLocal并不是一个Thread,而是Thread的局部变量。当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
ThreadLocal为每个线程提供单独一份存储空间,具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问。
ThreadLocal常用方法:
- public void set(T value)设置当前线程的线程局部变量的值
- public T get()返回当前线程所对应的线程局部变量的值
我们可以在LoginCheckFilter的doFilter方法中获取当前登录用户id,并调用ThreadLocal的set方法来设置当前线程的线程局部变量的值(用户id),然后在MyMetaObjectHandler的updateFill方法中调用ThreadLocal的get方法来获得当前线程所对应的线程局部变量的值(用户id)。
实现步骤:
- 编写BaseContext工具类,基于ThreadLocal封装的工具类 工具类注意方法设置静态
- LoginCheckFilter的doFilter方法中调用BaseContext来设置当前登录用户的id
- 在MyMetaObjectHandler的方法中调用BaseContext获取登录用户的id
BaseContext工具类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24/**
* 基于ThreadLocal封装工具类,用户保存和获取当前登录用户id
*/
public class BaseContext {
static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
/**
* 设置线程id为用户id
* @param id
*/
public static void setCurrentId(Long id){
threadLocal.set(id);
}
/**
* 获取线程id(用户id)
* @return
*/
public static Long getCurrentId(){
Long userId = threadLocal.get();
return userId;
}
}
MyMetaObjectHandler类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/**
* 自定义元数据对象处理器
*/
public class MyMetaObjectHandler implements MetaObjectHandler {
/**
* 插入时对公共字段进行填充
* @param metaObject
*/
public void insertFill(MetaObject metaObject) {
log.info("在插入时填写公共字段...");
log.info(metaObject.toString());
metaObject.setValue("createTime", LocalDateTime.now());
metaObject.setValue("updateTime",LocalDateTime.now());
metaObject.setValue("createUser",BaseContext.getCurrentId());
metaObject.setValue("updateUser",BaseContext.getCurrentId());
}
/**
* 更新时对公共字段进行填充
* @param metaObject
*/
public void updateFill(MetaObject metaObject) {
log.info("在更新时填写更新字段...");
log.info(metaObject.toString());
metaObject.setValue("updateTime",BaseContext.getCurrentId());
metaObject.setValue("updateUser",BaseContext.getCurrentId());
}
}
功能完善完毕
新增分类
需求分析
后台系统中可以管理分类信息,分类包括两种类型,分别是菜品分类和套餐分类。当我们在后台系统中添加菜品时需要选择一个菜品分类,当我们在后台系统中添加一个套餐时需要选择一个套餐分类,在移动端也会按照菜品分类和套餐分类来展示对应的菜品和套餐。
主要就是完善这两个接口的添加功能
数据模型
开发阶段需求方分析完毕,此时应该去关注数据库中创建的数据模型,了解数据之间的区别以及设定方式。
代码开发
- 添加分类功能
在开发业务功能前,先将需要用到的类和接口基本结构创建好:
- 实体类Category(直接从课程资料中导入即可)
- Mapper接口CategoryMapper
- 业务层接口CategoryService
- 业务层实现类CategoryServicelmpl
- 控制层CategoryController
这里是接口测试:
可以看到
由于请求数据是json传入后端,所以接口必须使用Requestody注解,这是由前端设定,后端按照约定开发
添加菜品功能,直接调用service中的save即可1
2
3
4
5
public R<String> save({ Category category)
categoryService.save(category);
return R.success("添加成功!");
}
异常在全局捕获器中被捕获,不需要做额外判断
- 分页查询功能
这是对应接口的信息,上面做过一次应该比较熟悉
类似的操作,再来一次
建议写在service层1
2
3
4
5
6
7
8
9
10
11
12
13
14
public R<Page> page(int page, int pageSize) {
// 1. 分页构造器
Page<Category> pageInfo = new Page<>();
// 2. 条件构造器
LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.orderByDesc(Category::getUpdateTime);
// 直接返回构造器即可
page(pageInfo,queryWrapper); // 执行条件映射
return R.success(pageInfo);
} - 删除分类
- 需求分析
在分类管理列表页面,可以对某个分类进行删除操作。需要注意的是当分类关联了菜品或者套餐时,此分类不允许删除。 - 业务开发
在开发代码之前,需要梳理一下整个程序的执行过程:
1、页面发送ajax请求,将参数(id)提交到服务端
2、服务端Controller接收页面提交的数据并调用Service删除数据
3、Service调用Mapper操作数据库
接口状态:
通过问号形式传参就不需要处理,直接写上即可,因为这里几乎明确了告诉mapping就用这个接收这个参数,所以可以不用添加对应注解,而无❓在前的就需要添加@PathVariable注解
Controller层Service层1
2
3
4
5
6
7
8
9
10
11
12
13/**
* 根据id删除分类
* @param ids
* @return
*/
public R<String> delete(Long ids){
log.info("删除分类,id为:{}",ids);
categoryService.remove(ids);
// categoryService.removeById(ids);
return R.success("分类信息删除成功");
}这个业务重点是检查删除的分类是否关联了菜品或者套餐,这里需要反馈异常到页面提供给使用人员看,那么此时就需要抛出异常,使用到全局异常捕获器去反馈,为了可控,异常类型需要手动抛出,那么此时需要写个自定义异常类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
private DishService dishService;
private SetmealService setmealService;
public R<Page> page(int page, int pageSize) {
// 1. 分页构造器
Page<Category> pageInfo = new Page<>();
// 2. 条件构造器
LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.orderByDesc(Category::getUpdateTime);
// 直接返回构造器即可
page(pageInfo,queryWrapper); // 执行条件映射
return R.success(pageInfo);
}
public void remove(Long id) {
// 1. 查询分类下是否有菜品关联
LambdaQueryWrapper<Dish> dishLambdaQueryWrapper = new LambdaQueryWrapper<>();
dishLambdaQueryWrapper.eq(Dish::getCategoryId, id);
int count1 = dishService.count(dishLambdaQueryWrapper);
if (count1 > 0) {
// 抛出异常,表明分类下有菜品关联
throw new CustomException("当前分类下关联了菜品,不能删除");
}
// 2. 查询分类是否与套餐关联
LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();
setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId, id);
int count2 = setmealService.count(setmealLambdaQueryWrapper);
if (count2 > 0) {
// 抛出异常,表明分类下有套餐关联
throw new CustomException("当前分类下关联了套餐,不能删除");
}
super.removeById(id);
}1
2
3
4
5
6
7
8/**
* 自定义业务异常类
*/
public class CustomException extends RuntimeException{
public CustomException(String msg){
super(msg);
}
}1
2
3
4
5
6
7
8
9
10
11/**
* 自定义异常捕获
* @param exception
* @return
*/
public R<String> exceptionHandle(CustomException exception){
log.error(exception.getMessage());
return R.error(exception.getMessage());
}
- 修改分类
直接看接口
- 业务开发:
1
2
3
4
5
6
7
8
9
10/**
* 修改分类信息
* @param category
* @return
*/
public R<String> update({ Category category)
categoryService.updateById(category); // ById并非是id,而是实体类对象
return R.success("更新成功!");
}菜品部分修改
业务目录:
- 文件上传下载
- 新增菜品
- 菜品信息分页查询
- 修改菜品心
文件交互
前置学习
上传介绍:
目前一些前端组件库也提供了相应的上传组件,但是底层原理还是基于form表单的文件上例如ElementUl中提供的upload上传组件:
服务端要接收客户端页面上传的文件,通常都会使用Apache的两个组件:
- commons-fileupload
- commons-io
Spring框架在spring-web包中对文件上传进行了封装,大大简化了服务端代码,我们只需要在Controller的方法中声明一个MultipartFile类型的参数即可接收上传的文件,例如:
但是底层还是借用了apache的组件
下载介绍:
文件下载,也称为download,是指将文件从服务器传输到本地计算机的过程。
通过浏览器进行文件下载,通常有两种表现形式:
- 以附件形式下载,弹出保存对话框,将文件保存到指定磁盘目录
- 直接在浏览器中打开
通过浏览器进行文件下载,本质上就是服务端将文件以流的形式写回浏览器的过程。
代码开发
首先我们来看接口可以提供的信息
url路径,以及请求方式post
文件上传实现
注意: 这里使用在配置文件定义的方式将文件路劲写死,较为优雅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// 前置:获取在配置文件中设置的路劲
private String basePath;
public R<String> upload(MultipartFile file){
// 1. 获取对应文件的文件名称
String originalFilename = file.getOriginalFilename();
// 2. 对文件名进行后缀截取
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
// 3. 使用UUID生成新文件名称并进行拼接
String s = UUID.randomUUID().toString();
String fileName = s + suffix;
// 4. 对配置文件设定的文件夹是否存在进行判断
File path = new File(basePath);
if(!path.exists()){
// 不存在
path.mkdir();
}
// 5. 对上传的文件进行转存
try {
file.transferTo(new File(basePath + fileName));
} catch (IOException e) {
throw new RuntimeException(e);
}
return R.success(fileName);
}文件下载实现
文件下载需要获取文件输出流,重定向对浏览器页面进行输出需要使用到HttpServletResponse response
获取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/**
* 文件下载功能
* @param name
* @param response
*/
public void download(String name , HttpServletResponse response){
try {
// 1. 文件流输入 读取文件内容
FileInputStream fileInputStream = new FileInputStream(new File(basePath + name));
// 2. 文件流输出 将文件内容写回浏览器
ServletOutputStream outputStream = response.getOutputStream();
// 作用是使客户端浏览器区分不同种类的数据,并根据不同的MIME调用浏览器内不同的程序嵌入模块来处理相应的数据。
response.setContentType("image/jpeg");
int len = 0;
byte[] bytes = new byte[1024];
while((len = fileInputStream.read(bytes)) != -1){
outputStream.write(bytes,0,len);
outputStream.flush();
}
// 3. 关闭资源
fileInputStream.close();
outputStream.close();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
文件操作部分记录
学习到的地方
- 文件上传 学习了几个api,对文件进行存储重定向 文件后缀截取,使用字符串的subString,使用“.”来分割
学会设置环境变量并在程序中被调用,对空文件进行判断创建 - 文件下载 文件流输出的复习,使用write api的固定用法 bytes
- 接收参数的方式 注解分类:@RequestParam 可省略,针对Get请求 @PathVariable 路径占位符 @RequestBody Post请求 json数据接收专属
学习到一个前端操作,对被隐藏的密码进行页面显示,只需要F12将密码对应的方式改成text即可
一个小问题:(为什么在Springboot中为什么有时候使用Autowired的时候会被警告,而使用Resource注解不会?)
Autowired是Spring提供的,它是特定IoC提供的特定注解,这就导致了应用与框架的强绑定,一旦换用了其他的IoC框架,是不能够支持注入的。而Resource是java提供的,它是Java标准,平常使用的IoC容器应当去兼容它,这样即使更换容器,也可以正常工作。
新增菜品
业务需求
在开发代码之前,需要梳理一下新增菜品时前端页面和服务端的交互过程:
- 页面(backend/page/food/add.html)发送ajax请求,请求服务端获取菜品分类数据并展示到下拉框中
- 页面发送请求进行图片上传,.请求服务端将图片保存到服务器
- 页面发送请求进行图片下载,将上传的图片进行回显
- 点击保存按钮,发送ajax请求,将菜品相关数据以json形式提交到服务端开发新增菜品功能,其实就是在服务端编写代码去处理前端页面发送的这4次请求即可。
首先是第一个业务修改
对应controller层1
2
3
4
5
6
7
8
9/**
* 查询可供选择的分类
* @param category
* @return
*/
public R<List<Category>> list(Category category){
return categoryService.list(category);
}1
2
3
4
5
6
7
8
9
10
11
public R<List<Category>> list(Category category) {
// 创建条件构造器
LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
// 填充条件构造器条件
queryWrapper.eq(category.getType()!=null,Category::getType,category.getType());
// 添加查询到之后的排序条件
queryWrapper.orderByAsc(Category::getSort).orderByDesc(Category::getUpdateTime);
List<Category> list = categoryService.list(queryWrapper);
return R.success(list);
}
添加菜品接口实现
首先出现一个问题:
这里是不光涉及到一个表的数据,所以单独一个Dish不足以接收,所以这里就出现了一个新的数据传输对象-DTO
DTO:全称为Data Transfer Object,即数据传输对象,一般用于展示层与服务层之间的数据传输。
这里直接使用一个DTO数据进行接收
Controller层1
2
3
4
5
public R<String> addDish({ DishDto dishDto)
dishService.saveWithFlavor(dishDto);
return R.success("菜品添加成功!");
}
Service层1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private DishFlavorService dishFlavorService;
public void saveWithFlavor(DishDto dishDto) {
// 首先对dish的内容进行封装
this.save(dishDto);
// 对flavor中的内容进行封装 其中flavor不存在id属性,所以此处需要额外遍历添加
List<DishFlavor> flavors = dishDto.getFlavors();
Long dishId = dishDto.getId();
flavors = flavors.stream().map((flavor)->{
flavor.setDishId(dishId);
return flavor;
}).collect(Collectors.toList());
dishFlavorService.saveBatch(flavors);
}
菜品信息分页功能实现
这是第三次接触分页,相当于进行一次复习,有段时间没碰项目,所以此处会写的比较详细
具体需求
- 页面(backend/page/food/list.html)发送ajax请求,将分页查询参数(page、pageSize、name)
提交到服务端,获取分页数据 - 页面发送请求,请求服务端进行图片下载,用于页面图片展示
三个参数分别代表对应对应页码以及对应的尺码,和需要可能会进行查询的名称
对于此处返回值设置为page类型,基于MP实现分页有三个步骤 1. 分页构造器 2. 条件构造器 3. 排序条件
在条件构造器中一般是,前一个设置不为空的条件,之后设置匹配条件
最后在本身基于MP的page方法中添入这两个构造器即可
stream流:使用map方法就是将list集合中的每个元素单独的拿出来,然后进行依次赋值,最后注意还需要进行收集
1 |
|
这样可以实现分页查询,但是出现了一个问题就是category的Name无法进行显示,那么此时需要对项目进行改进
此时改进的方法就是和stream进行拷贝
但是Page类中records属性不需要进行拷贝因为需要对其进行相应的赋值操作,所以此时需要处理一下
在基础处理之后再进行拷贝操作
代码开发: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
public R<Page> page(int page, int pageSize, String name) {
// 1. 创建分页构造器
Page<Dish> pageInfo = new Page<>(page,pageSize);
Page<DishDto> dishDtoPageInfo = new Page<>();
// 2. 创建条件构造器
LambdaQueryWrapper<Dish> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(name!=null, Dish::getName,name);
// 3. 排序条件
queryWrapper.orderByDesc(Dish::getUpdateTime);
page(pageInfo,queryWrapper);
// 进行一次拷贝
BeanUtils.copyProperties(pageInfo,dishDtoPageInfo,"records");
List<Dish> records = pageInfo.getRecords();
// 对菜品分类进行特殊处理
List<DishDto>list = records.stream().map((dish) -> {
DishDto dishDto = new DishDto();
BeanUtils.copyProperties(dish,dishDto);
Long categoryId = dish.getCategoryId();
// 给dishDto中的菜品分类名称赋值
Category category = categoryService.getById(categoryId);
if(category != null){
dishDto.setCategoryName(category.getName());
}
return dishDto;
}).collect(Collectors.toList());
dishDtoPageInfo.setRecords(list);
return R.success(dishDtoPageInfo);
}
处理结束
修改菜品
需求分析:
首先是查询单个菜品的接口实现
第二是一个对dishDto类型保存处理
难度出现在进行多表操作,往往是通过一个中间DTO子类去实现数据交互
根据菜品id查询菜品同时进行回显吗,实现对应接口即可
实现功能1:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public R<DishDto> getByIdWithFlaovr(Long id) {
Dish dish = getById(id);
DishDto dishDto = new DishDto();
BeanUtils.copyProperties(dish,dishDto);
// 这里只需要对flavor进行相应处理
LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DishFlavor::getDishId,id);
List<DishFlavor> flavors = dishFlavorService.list(queryWrapper);
dishDto.setFlavors(flavors);
return R.success(dishDto);
}
实现功能2:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public R<String> updateWithFlaovr(DishDto dishDto) {
// 修改dish基本信息
updateById(dishDto);
// 删除flavor中的存储
LambdaQueryWrapper<DishFlavor> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DishFlavor::getDishId,dishDto.getId());
dishFlavorService.remove(queryWrapper);
// 重新进行信息插入
List<DishFlavor> flavors = dishDto.getFlavors();
flavors = flavors.stream().map(flavor -> {
flavor.setDishId(dishDto.getId());
return flavor;
}).collect(Collectors.toList());
dishFlavorService.saveBatch(flavors);
return R.success("修改成功!");
}
套餐管理
同样难点在于多表操作,与上面部分类似,主要是几个接口的构建,用到的有多表查询,其中借助了dto操作,之后是分页page操作
需求分析:
在开发代码之前,需要梳理一下新增套餐时前端页面和服务端的交互过程:
- 页面(backend/page/combo/add.html)发送ajax请求,请求服务端获取套餐分类数据并展示到下拉框中
- 页面发送ajax请求,请求服务端获取菜品分类数据并展示到添加菜品窗口中
- 页面发送ajax请求,请求服务端,根据菜品分类查询对应的菜品数据并展示到添加菜品窗口中
- 页面发送请求进行图片上传,请求服务端将图片保存到服务器
- 页面发送请求进行图片下载,将上传的图片进行回显
- 点击保存按钮,发送ajax请求,将套餐相关数据以json形式提交到服务端
共六次交互过程,其中4,5已被实现 需要处理的接口是1,2,3,6(但实际上这里并非开发顺序)
我们按照业务逻辑的顺序进行记录:
分页查询套餐展示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
public R<Page> page(int page, int pageSize, String name) {
// 创建分页构造器
Page<Setmeal> pageInfo = new Page<>(page,pageSize);
Page<SetmealDto> setmealDtoPage = new Page<>();
// 创建条件构造器
LambdaQueryWrapper<Setmeal> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.like(name != null,Setmeal::getName,name);
// 添加排序条件
queryWrapper.orderByDesc(Setmeal::getUpdateTime);
// 进行赋值
page(pageInfo,queryWrapper);
// 对page中的基础信息进行拷贝
BeanUtils.copyProperties(pageInfo,setmealDtoPage,"records");
List<Setmeal> records = pageInfo.getRecords();
// 遍历赋值操作
List<SetmealDto> list = records.stream().map(record -> {
SetmealDto setmealDto = new SetmealDto();
// 基本信息进行拷贝
BeanUtils.copyProperties(record, setmealDto);
// 剩余信息赋值
Long categoryId = record.getCategoryId();
String categoryName = categoryService.getById(categoryId).getName();
setmealDto.setCategoryName(categoryName);
return setmealDto;
}).collect(Collectors.toList());
setmealDtoPage.setRecords(list);
return R.success(setmealDtoPage);
}
对于dto分页类拷贝时,需要忽略的属性是record,原因是他们的泛型不同
请求服务端获取菜品分类数据并展示到添加菜品窗口中:1
2
3
4
5
6
7
8
9/**
* 查询可供选择的分类
* @param category
* @return
*/
public R<List<Category>> list(Category category){
return categoryService.list(category);
}
Service层1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/**
* 根据类别查询对应菜品
* @param category
* @return
*/
public R<List<Category>> list(Category category) {
// 创建条件构造器
LambdaQueryWrapper<Category> queryWrapper = new LambdaQueryWrapper<>();
// 填充条件构造器条件
queryWrapper.eq(category.getType()!=null,Category::getType,category.getType());
// 添加查询到之后的排序条件
queryWrapper.orderByAsc(Category::getSort).orderByDesc(Category::getUpdateTime);
List<Category> list = categoryService.list(queryWrapper);
return R.success(list);
}
保存套餐
1 | /** |
requestParam注解需要注意:接收的参数来自于请求头
responseBody注解是什么?这里8
p.s. 当处理业务逻辑时,可以选择优先去写出sql语句之后再写逻辑
删除套餐
需要注意这里是同时对两张表进行操作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/**
* 根据id删除分类,删除之前需要进行判断
* @param id
*/
public void remove(Long id) {
LambdaQueryWrapper<Dish> dishLambdaQueryWrapper = new LambdaQueryWrapper<>();
//添加查询条件,根据分类id进行查询
dishLambdaQueryWrapper.eq(Dish::getCategoryId,id);
int count1 = dishService.count(dishLambdaQueryWrapper);
//查询当前分类是否关联了菜品,如果已经关联,抛出一个业务异常
if(count1 > 0){
//已经关联菜品,抛出一个业务异常
throw new CustomException("当前分类下关联了菜品,不能删除");
}
//查询当前分类是否关联了套餐,如果已经关联,抛出一个业务异常
LambdaQueryWrapper<Setmeal> setmealLambdaQueryWrapper = new LambdaQueryWrapper<>();
//添加查询条件,根据分类id进行查询
setmealLambdaQueryWrapper.eq(Setmeal::getCategoryId,id);
int count2 = setmealService.count();
if(count2 > 0){
//已经关联套餐,抛出一个业务异常
throw new CustomException("当前分类下关联了套餐,不能删除");
}
//正常删除分类
super.removeById(id);
}
权限认证
此处记录的是:spring sercuity
使用方式:一个配置类即可
作用:
- 登录认证 权限认证 两者是分开的 在配置类中分别是使用两个方法进行验证
- 登录认证时:可以使用方法去传入密码校验方式 ,但查询sql业务判断用户是否存在是需要自己去写的
- 权限认证:充当过滤器,赋予登录用户权限
需要使用可查文档
项目优化
mysql主从复制
一个主库可以有多个从库
示意:
MySQL复制过程分成三步:
- master将改变记录到二进制日志(binary log)
- slave将master的binary log拷贝到它的中继日志(relay log)
- slave重做中继日志中的事件,将改变应用到自己的数据库中
主库需要创建一个用户给予从库,使其具有部分信息
读写分离
面对日益增加的系统访问量,数据库的吞吐量面临着巨大瓶颈。
对于同一时刻有大量并发读操作和较少写操作类型的应用系统来说,将数据库拆分为主库和从库,主库负责处理事务性的增删改操作,从库负责处理查询操作,能够有效的避免由数据更新导致的行锁,使得整个系统的查询性能得到极大的改善。
nginx
一种web服务器,和tomcat(服务)不同
- 正向代理
不直接去访问,通过代理服务器去访问,访问的结果从该服务器返回到本机 - 反向代理
这二者的区别就在于前者是明确知道存在这样一个代理服务存在的,而后者不知道
作用统一入口,组成局域网,隐藏了真正的web服务器应用 负载均衡
使用的原理还是反向代理,但是对应的是多台服务器
使用轮询算法(循环)
负载均衡策略:
前后端分离项目开发
主要变化,工程拆分,后端部署再tomcat,前端静态资源部署在nginx中
开发流程:
定义接口:
使用YApi 常用的api管理工具
Swagger技术:
目的:生成接口
底层集成swagger的工具
开启knf4j需要使用的两个注解:
返回类型,文件配置
swagger常用注解(使得接口文档可读性更高)用可查
项目部署
服务部署:
nginx反向代理url重写逻辑:
首先部署前端项目,开启后端服务,繁琐的操作可以使用脚本文件统一执行
如果出现项目内容更新只需进行git推送,之后再重新执行脚本文件即可,这是一种统一化执行的思想更加快捷,学会使用脚本去简化繁琐操作
此时也更明白之前学习Linux本地操作的意义,项目上线后端都是linux系统操作,线上服务器也是Linux系统,所以此时重要性体现出了