本项目用于辅助SpringCloud相关技术的学习,通过代码实践进行学习。

设计

经过对SpringCloud的基础知识学习后,希望将它应用到酒店管理系统这个项目中去。我的规划如下:

  • 在功能需求已知的情况下,分析并将业务拆解成多个模块实现微服务架构
  • 进行初步的实体类设计,数据库设计
  • 优先使用已掌握技能完成基础功能模块,如处理秒杀库存暂时不考虑使用分布式锁,目前计划使用synchronized解决

当前阻力:

  • 前端设计
  • 用户权限控制管理这块没有系统的学习,只能基础的使用JWT进行token解析
  • Flask框架并没有实际上手写过,也并没有使用Python进行过与数据库交互的项目开发,没有编写过脚本
  • 消息中间件没有过多实际编码经验,尚无法确认能否顺利投入到项目使用中

理想状态:

  • 能够熟练的使用SpringCloud对项目各个服务模块进行开发
  • 引入消息中间件来实现对高并发的支持,引入redis缓存来提高秒杀效率
  • 补充SpringSecurity知识,对权限认证进行完善

2023.09.13

redis和SpringSecurity在后续学习的时候再用于这个项目

2024.11.22

把我自己都看笑了,太能🕊了

模块设计

仅针对Java部分的SpringCloud微服务设计

  1. 用户管理模块:负责处理用户的注册、登录、基本信息维护等功能。
  2. 酒店信息管理模块:负责处理酒店列表、基本信息、细节信息的维护等功能。
  3. 订单管理模块:负责处理用户预定的订单,包括浏览订单等功能。
  4. 管理员客户管理模块:负责帮助客户重置密码,生成新的随机密码,删除用户

UML

针对酒店管理系统的业务进行数据库设计和类图的设计

数据库设计

t_user

字段名 类型 长度 注释
id int 用户id
username varchar 255 用户名
password varchar 255 密码
salt varchar 255 盐值
sex int 性别(女0,男1)
type int 类型(0未成年人,1成年人)
identity int 身份(0为普通用户,1为用户管理员,2为酒店管理人员,3为酒店管理员)
idcn char 18 身份证号码
real_name varchar 255 真实姓名
phone varchar 255 电话号码
email varchar 255 邮箱
created_at datetime 创建时间
updated_at datetime 修改时间

t_hotel

字段名 类型 长度 注释
id int 用户id
name varchar 255 酒店名称
address varchar 255 酒店地址
introduction varchar 255 酒店介绍
phone varchar 联系方式
stars int 星级
brand varchar 255 品牌
business_district varchar 255 所属商圈
created_at datetime 创建时间
updated_at datetime 修改时间

t_order

字段名 类型 长度 注释
id int 订单id
user_id int 255 用户id
room_id int 255 房间id
check_in_date datetime 255 入住日期
check_out_date datetime 退房日期
room_num int 房间数量
expected_guest_num int 预期入住人数
has_children int 18 是否有儿童(0无1有)
status int 255 订单状态(0未支付,1已支付,2已取消)
price decimal 255 订单价格
created_at datetime 创建时间
updated_at datetime 修改时间

t_room

字段名 类型 长度 注释
id int 房间id
hotel_id int 所属酒店id
num int 剩余数量
type varchar 255 房间类型(0单人间,1双床房,2大床房)
original_price decimal 原价
price decimal 当前价格
created_at datetime 创建时间
updated_at datetime 修改时间

t_facilities

字段名 类型 长度 注释
id int 设施id
name varchar 255 设施名称
is_free int 是否免费(0付费,1免费)
created_at datetime 创建时间
updated_at datetime 修改时间

t_hotel_facilities

字段名 类型 长度 注释
id int
hotel_id int 酒店id
facilities_id int 设施id
created_at datetime 创建时间
updated_at datetime 修改时间

2023.09.13

t_facilities设计有问题,is_free字段应该放在t_hotel_facilities中而不是t_facilities中;其次为用户增加了一个hotel_id的字段,提供给酒店管理人员标识其管理的酒店

类图

有机会再补吧,先🕊了

2023.09.13

不会再补了,彻底🕊了

用户业务

权限认证

以前写项目的时候根本没有怎么考虑过权限问题,很简单的做了一个token验证就结束了,现在自己开始考虑到这些问题发现自己根本想不到什么好的解决方案。

首先担心的是同角色之间资源的相互访问问题,也许可以通过验证用户的角色身份来解决一部分问题,但是如果同角色试图访问其他用户的资源,不对token中的用户id进行解析是没法解决的。这样的话我在Gateway的微服务中还需要添加与数据库的连接,我不知道是不是必须如此…我主观上是这么想的,确实想不到更优雅的办法,但是感觉这样子会很笨。

@Order(-1)
@Component
public class AuthorizationFilter implements GlobalFilter {

/**
*
* @param exchange 包含请求头,请求体的信息
* @param chain 过滤器链,若通过过滤器将其放行至下一个过滤器
* @return Mono类型的结果,若通过过滤器只需要返回chain.filter(exchange)即可
*/
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求
ServerHttpRequest request = exchange.getRequest();
// 从请求中获取请求头
HttpHeaders headers = request.getHeaders();
// 请求路径
String requestPath = request.getURI().getPath();
// 判断是否需要核验token

// 需要token
if(tokenShouldCheck(requestPath)) {
// 不包含token
if(!headers.containsKey("Authorization")) {
// 身份验证失败,设置状态码
ServerHttpResponse response = exchange.getResponse();
// 参数是HttpStatus类型的,未登录状态码为401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 核验token是否有效,以及用户权限能否获取资源
String token = headers.getFirst("Authorization");
Claims claims = JwtUtils.getClaimsByToken(token);
// 过期
if(claims.getExpiration().getTime() < new Date().getTime()) {
// token过期,设置状态码
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 没有过期,对权限进行验证
else {

}
}

// 传递给下一个过滤器
return chain.filter(exchange);
}

public Boolean tokenShouldCheck(String path) {
return !path.equals("/users/login") && !path.equals("/users/register");
}

}

整段代码我只能说,和优雅是完全不沾边,感觉过滤器只学到了皮毛…


2023/09/06

在昨晚写下博客之后又自己捣鼓了好久,发现我确实学的不精…

我最开始的思路是用feign来获取用户信息,但是我居然认为feign的远程调用要经过http,所以我放弃了这个想法。如果真是这样的话,那微服务中使用feign调用其他微服务来实现功能都没法成立了,毕竟你在实现中哪来的token(如果要做token验证)

于是使用feign解决了,但是确实不算优雅

@Order(-1)
@Component
public class AuthorizationFilter implements GlobalFilter {
@Autowired
UserClient userClient;

/**
*
* @param exchange 包含请求头,请求体的信息
* @param chain 过滤器链,若通过过滤器将其放行至下一个过滤器
* @return Mono类型的结果,若通过过滤器只需要返回chain.filter(exchange)即可
*/

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取请求
ServerHttpRequest request = exchange.getRequest();
// 从请求中获取请求头
HttpHeaders headers = request.getHeaders();
// 从请求中获取参数
// MultiValueMap<String, String> params = request.getQueryParams();
// 请求路径
String requestPath = request.getURI().getPath();

// 需要token
if(tokenShouldCheck(requestPath)) {
// 不包含token
if(!headers.containsKey("Authorization")) {
// 身份验证失败,设置状态码
ServerHttpResponse response = exchange.getResponse();
// 参数是HttpStatus类型的,未登录状态码为401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
// 核验token是否有效,以及用户权限能否获取资源
String token = headers.getFirst("Authorization");
// 空token
if(token == null) {
// 身份验证失败,设置状态码
ServerHttpResponse response = exchange.getResponse();
// 参数是HttpStatus类型的,未登录状态码为401
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
Claims claims = JwtUtils.getClaimsByToken(token);
// 过期
if(claims.getExpiration().getTime() < new Date().getTime()) {
// token过期,设置状态码
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}

// 没有过期
else {
Integer id = Integer.valueOf(claims.getSubject());
User user = userClient.findUserById(id);
// 权限不足
if(!hasAuthority(user, requestPath)) {
ServerHttpResponse response = exchange.getResponse();
// 403权限不足
response.setStatusCode(HttpStatus.FORBIDDEN);
return response.setComplete();
}
}
}

// 传递给下一个过滤器
return chain.filter(exchange);
}

public Boolean tokenShouldCheck(String path) {
return !path.equals("/users/login") && !path.equals("/users/register") && !path.equals("/users/sendVerifyCode");
}

/**
* 判断是否有权限访问当前url下的资源
* @param user 用户
* @param requestPath 请求路径
* @return 是否有权限
*/
public Boolean hasAuthority(User user, String requestPath) {
// 判断管理员权限
if(requestPath.equals("/users")) {
return user.getIdentity() == Identity.Admin.ordinal();
}
// 已/users/为开头
else if (requestPath.startsWith("/users/")) {
String paramId = requestPath.split("/")[2];
String id = String.valueOf(user.getId());
// id正确
if(paramId.equals(id)) {
return true;
}
// id错误,判断是否有管理权限
else {
return user.getIdentity() == Identity.Admin.ordinal();
}
}
// 其他url
else {
return true;
}
}

}

其他

其他功能以前都写烂了,没什么好说的感觉,不能很好的完成权限认证是我认为这项目开局的败笔😡

酒店业务

Feign

这两天几乎遇到的所有难题都和Feign有关,可以说被Feign狠狠拷打了…

错误一

feign.codec.DecodeException: Error while extracting response for type [java.util.ArrayList<com.magus.api.entity.Room>] and content type [application/json];

这两天见到的最多的就是这句话,然而造成这个错误的原因是多种多样的。

我第一次遭遇这个错误是返回值不匹配造成的,起因是我在网上看到了和我同样的操作,在Controller中返回自定义的Json类,他说他在修改为Java自封装类型List之后就解决了(其实自定义类完全是可以的,但是需要创建无参构造函数)。因此我进行了尝试,以至于后面改的太乱了,有的地方是JsonResult,有的地方是User,不匹配造成了这个错误。

然后还有一个原因则是我的Feign中缺少一个配置

package com.magus.base.config;

import java.util.stream.Collectors;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;

/**
* feign的http客户端配置
*/
@Configuration
public class FeignConfig {
/**
*No qualifying bean of type ‘org.springframework.boot.autoconfigure.http.HttpMessage
*/
@Bean
@ConditionalOnMissingBean
public HttpMessageConverters messageConverters(ObjectProvider<HttpMessageConverter<?>> converters) {
return new HttpMessageConverters(converters.orderedStream().collect(Collectors.toList()));
}

}

HttpMessageConverters在一些版本中无法被装配,因此我们添加一个Feign的配置类对他进行@Bean装配

错误二

这个错误又难找又逆天,网上也搜罗不到相关的信息,我只能一点点的推测来判断。

@FeignClient(value = "roomProvider")
public interface RoomClient {
// 根据id获取room
@GetMapping("/rooms/{id}")
Room findRoomById(@PathVariable Integer id);

// 获取所有room
@GetMapping("/rooms")
ArrayList<Room> findAllRooms();

// 根据酒店id获取room
@GetMapping("/rooms/roomsOfHotel")
ArrayList<Room> roomsOfHotel(@RequestParam("hotelId") Integer hotelId);
}

如果FeignClient中定义的接口需要带参数的话,而且参数本身不是在url上,那么一定要添加注解@RequestParam,不然是无法识别到对应的controller接口的

@RequestMapping("roomsOfHotel")
public ArrayList<Room> roomsOfHotel(Integer hotelId) {
return roomService.findRoomByHotel(hotelId);
}

可以看到我的controller接口名称,返回值,参数名,一切都是吻合的,但是依然要加@RequestParam注解,不然收获一小时调试hhh

查询酒店详细信息

这个函数的实现很好的使用了Feign来跨服务调用

@Override
public HotelDetail getDetail(Integer id) {
Hotel hotel = hotelMapper.selectById(id);
// 酒店id
Integer hotelId = hotel.getId();
HotelDetail detail = new HotelDetail(hotel);
// 设置房间列表
ArrayList<Room> rooms = roomClient.roomsOfHotel(hotelId);
detail.setRooms(rooms);
// 设置设施列表
ArrayList<Facility> facilities = facilityClient.facilitiesOfHotel(hotelId);
detail.setFacilities(facilities);
return detail;
}

设计了一个dto来展示酒店详细信息

@Data
public class HotelDetail {
// 酒店地址
private String address;
// 品牌
private String brand;
// 商圈
private String businessDistrict;
// 酒店id
private Integer id;
// 酒店简介
private String introduction;
// 酒店名称
private String name;
// 联系方式
private String phone;
// 星级
private Integer stars;
// 设施
ArrayList<Facility> facilities;
// 房间列表
ArrayList<Room> rooms;

public HotelDetail(Hotel hotel) {
this.stars = hotel.getStars();
this.phone = hotel.getPhone();
this.name = hotel.getName();
this.introduction = hotel.getIntroduction();
this.id = hotel.getId();
this.businessDistrict = hotel.getBusinessDistrict();
this.brand = hotel.getBrand();
this.address = hotel.getAddress();
}
}

订单业务

下单

下单功能的设计和代码编写过程中都遭遇不少困难。

设计

首先是对于库存进行加锁来避免多线程下出现库存负数的情况,在设计方面目前考虑到单服务器部署下使用jvm的synchronized来解决。

其次是异步方面的设计,要求是“将耗时的操作放到后台,而界面可以显示提示信息,并且响应等的不耐烦的用户的取消操作。”

前端设计应该需要用到异步和回调,在响应到达之前进入一个正在下单的界面,并且可以选择取消,响应到达则关闭这个界面。针对此,后端需要设计的只是取消这个功能,所以我在数据库设计方面对订单状态增加了一个下单中的状态OrderStatus.CREATING,订单在插入数据库时默认是这个状态。在用户发送取消的请求后,取消服务的实现方面,我首先会对订单的状态进行查询,因为并不确认此时的下单业务是否完成,订单状态可能处于下单中,下单失败,待支付三种状态。如果处于下单中的状态,将其修改为取消状态,是有问题的,因为代表着下单服务中的检查库存、修改订单状态还没有发生,即使将其设置为取消状态,后续也会被覆盖。

因此我认为比较合理的设计是多次询问订单状态,直至变化为下单失败或待支付,如果多次询问仍为下单中,则抛出取消失败的异常返回给前端。

Feign

在Feign的使用上,之前学习并没有学到参数传递方面的知识点,现在已经弄清楚了。结合上一篇酒店模块中的进行一个整理

路径中的参数

当请求路径中代有参数,并且想要将其提取出来,需要使用@PathVariable注解,同时参数名和类型与Controller中保持一致

FeignClient

@GetMapping("/rooms/{id}")
Room findRoomById(@PathVariable Integer id);

Controller

@RequestMapping("{id}")
public Room findRoomById(@PathVariable Integer id) {
return roomService.findRoomById(id);
}

方法中的参数

当接口方法中有参数时,使用@RequestParam注解,同时参数名和类型与Controller中保持一致,Controller中不需要@RequestParam注解

FeignClient

@GetMapping("/rooms/roomsOfHotel")
ArrayList<Room> roomsOfHotel(@RequestParam("hotelId") Integer hotelId);

Controller

@RequestMapping("roomsOfHotel")
public ArrayList<Room> roomsOfHotel(Integer hotelId) {
return roomService.findRoomByHotel(hotelId);
}

方法中自定义类

当接口方法中想要传递可序列化类时,使用@RequestBody注解,同时参数名和类型与Controller中保持一致,Controller中需要@RequestBody注解,很重要,一定要加

FeignClient

@GetMapping("/rooms/updateRoom")
JsonResult updateRoom(@RequestBody Room room);

Controller

@RequestMapping("updateRoom")
public JsonResult updateRoom(@RequestBody Room room) {
roomService.updateRoom(room);
JsonResult result = new JsonResult(OK);
result.setMessage("更新房间信息成功");
return result;
}

代码

OrderService

@Override
public String addOrder(Order order) {
LocalDate start = order.getCheckInDate();
LocalDate end = order.getCheckOutDate();
long days = ChronoUnit.DAYS.between(start, end);
// 对预定日期合理性做检查
if(days <= 0) {
throw new OrderDateException("预定日期错误");
}
// 计算价格
Room room = roomClient.findRoomById(order.getRoomId());
Integer roomNum = order.getRoomNum();
Double price = roomNum * days * room.getPrice();
order.setPrice(price);
// 将订单状态设置为下单中并插入数据库
Date date = new Date();
order.setCreatedAt(date);
order.setUpdatedAt(date);
order.setStatus(OrderStatus.CREATING.ordinal());
// 操作数据库
if(orderMapper.insert(order) != 1) {
throw new AddOrderException("创建订单失败");
}
String result;
// 对库存进行核验,如果库存满足则可以下单
if(checkStorage(room.getId(), roomNum)) {
// 下单成功
order.setStatus(OrderStatus.UNPAID.ordinal());
result = "订单待支付";
} else {
// 库存不满足,订单状态设置为失败
order.setStatus(OrderStatus.FAIL.ordinal());
result = "下单失败,库存不足";
}
// 对状态进行更新
if(orderMapper.update(order) != 1) {
throw new UpdateOrderException("更新订单状态失败");
}
return result;
}

@Override
public void cancel(Integer id) {
// 对订单状态进行检查
Order order = orderMapper.selectById(id);
// 仍处在下单中的无法进行取消,必须等待状态变为失败或未支付
while(order.getStatus() == OrderStatus.CREATING.ordinal()) {
order = orderMapper.selectById(id);
}
// 跳出循环则订单状态理论上变为失败和未支付

// 下单成功处于未支付状态,取消订单需要还原库存
if(order.getStatus() != OrderStatus.FAIL.ordinal()) {
// 不是失败也不是未支付,状态错误
if(order.getStatus() != OrderStatus.UNPAID.ordinal()) {
throw new OrderStatusException("订单状态错误");
}
// 未支付房间库存还原(这里应该使用事务)
Room room = roomClient.findRoomById(order.getRoomId());
room.setNum(room.getNum() + order.getRoomNum());
roomClient.updateRoom(room);
}
// 下单失败无需还原房间库存,状态设置为取消下单即可
order.setStatus(OrderStatus.CANCEL.ordinal());
if(orderMapper.update(order) != 1) {
throw new UpdateOrderException("取消订单失败");
}
}

@Override
public synchronized Boolean checkStorage(Integer roomId, Integer roomNum) {
// 绝对的低效率
Room room = roomClient.findRoomById(roomId);
if(room.getNum() >= roomNum) {
room.setNum(room.getNum() - roomNum);
JsonResult result = roomClient.updateRoom(room);
// 库存充足
return result.getCode() == 200;
}
// 库存不足
return false;
}

只能说都能达到想要的效果,但是效率都不够高,多次的访问数据库进行查询就意味着效率的低下。另外就是cancel中询问订单状态应该设置一定的重试次数,使用while循环是有死循环的风险的(我这里图省事了hhh)

总结

前端

前端可以说完全没做,除了flask用了一些别人写好的模板,用了一下bootstrap和jQuery,可以说是很简陋。如果有机会的话想学习一下React,之前有过Vue的入门经历,还是想对这些东西都有个初步的认识(最好都能看懂),为了在以后的前后端沟通中更加方便吧

后端

Java

通过这个项目自主学习和实践了一下SpringCloud的基本用法,包括Nacos,Eureka,GateWay,Feign等。也在实践中理解了一些新的概念,像是负载均衡,反向代理等。

美中不足的是没有做服务熔断降级这一块,原计划是再对Sentinel进行一下学习然后使用到这个项目中的;同时MQ和Redis也没有用到,除了使用JVM处理并发以外的其他方法也没有尝试,像用Zookeeper的锁,用乐观锁悲观锁这些都没有尝试,有机会希望都能把这些知识补上

Python

通过这个项目也是把python开发实践了一下,之前只做过简单的爬虫,或者是小模型的训练,没有做过Web应用这方面的。Flask框架确实很方便,配合jinja2可以很快速的搭建一些简易的平台,在前端中直接使用Python语句确实很爽。但是现在主流还是前后端分离吧,专业的人干专业的事,前端还是掌握的太少了。