Star描述项目
1.S->situation(背景)
2.T->task(任务)
3.A->action(动作)
4.R->result(结果)
1.S
在学习 Java 后端开发过程中,为了提升对 SpringBoot 架构和企业级开发流程 的理解,我参与开发了一个在线点餐系统 。
这个系统包含了两个端: 用户端:使用的是小程序开发的; 管理端使用的是Vue开发的;
这个项目包含的技术栈有:Spring boot,Spring Mvc,Mysql,Mybatis,Redis,JWT,Websocket;
这个项目采用的是前后端分离的架构,后端提供Restful API
管理端主要是商家来使用,提供餐品的管理功能,包含了 员工管理模块来进行员工的登录及其他相关操作,菜品管理,套餐管理模块来进行菜品,模块的增删改等操作 , 菜品分类管理模块来进行菜品分类的增删改查操作,订单管理模块来搜索和查看订单,变更订单状态,销量统计模块来统计营业额,用户,订单和销量排名, 工作台模块 来提供今日运营数据以及订单,菜品,套餐总览;
用户端,给用户点餐等使用, 包含了用户登录模块调用微信小程序登录接口实现登录功能; 菜品,套餐模块来查询菜品,套餐的信息;购物车模块,来在购物车里添加和删除菜品; 订单模块提供下单,微信支付,查询订单,取消订单,再来一单,催单等功能;
2.T
我在项目中负责后端开发,根据接口文档信息来进行后端各大核心模块的开发;
以及JWT登录认证和拦截器 , Redis缓存优化等 ** ,以及时间转换器**对时间格式问题进行改良等;
3.A
1 用户登录认证
我实现了 基于 JWT 的无状态登录认证机制。
追问:
Q1:(这里可能就引出了 cookie , session ,token 的区别,以及为什么使用 JWT 而不使用cookie session 或者普通的token呢;)
Q2: (可能要你详细讲出JWT的三大部分及解密机制)
R1: cookie是客户端发起请求,服务器生成的,并且发送给客户端保存的; 因为保存在客户端中,所以是不安全的,用户可以对其修改来查看到其他人的信息;
session 是存储在服务器中的,并通过session ID来标识每个客户端的会话(这个session ID通常通过Cookie存储在客户端),服务器会根据Session ID来找到对应的session来查找对应会话数据, 相较于单用cookie 更安全,但是每来一个会话,就要在服务器内开辟一块内存空间,会造成大量内存消耗,对于大量用户的使用并不推荐;
**token ** 是一种基于令牌的身份验证机制,通常用于无状态的身份验证系统; Token由服务器生成并发送给客户端,客户端发送请求时需要通过Authorization请求头携带该Token; 服务器验证Token有效性和权限来完成身份校验; 所以Token 适用于Restful API这种无状态的认证系统;JWT属于token的一种,是一个Base64 编码和数字签名的 JSON 对象。
| 特性 |
Cookie |
Session |
Token (如 JWT) |
| 存储位置 |
客户端(浏览器) |
服务端(内存/数据库/Redis) |
客户端(LocalStorage/Header) |
| 状态管理 |
有状态(依赖服务器来维护状态) |
有状态(占用服务器内存) |
无状态(服务器不存数据) |
| 安全性 |
易受 CSRF 攻击 |
相对较高(数据在服务端) |
较高(防篡改,需防 XSS) |
| 跨域支持 |
需要特定配置才能跨域 |
通常不支持 |
支持 |
| 扩展性 |
低 |
低(多台服务器需做 Session 同步) |
极高(适合集群和分布式) |
R2: JWT包含Header,Payload,Signature三部分;Header存储JWT和算法声明,Payload存储业务数据,Signature对前两部分加上特定的密钥经过一定算法生成; 如果对前两部分修改,Signature就会对应不上产生错误;
苍穹外卖不使用普通Token,的原因是普通Token只是一个没有意义的字符串,需要去服务器查找存储的数据; 而JWT将数据存储在payload中,只需要校验就能拿到对应数据,速度更快,存储消耗更少;
JWT只要服务器配置了对应的密钥,就能校验,对跨域的支持更好,普通Token还要搭建一个统一的 Redis 集群来同步 Token 信息;
实现过程:
1.定义了一个jwtproperties类来保存一些相关属性,key,ttl,tokenname;其中定义了管理端和用户端两组属性
2.在配置文件中定义相关属性,在jwtproperties类中用@configuration properties(prefix = “sky.jwt根据自己定义的来写”)来匹配
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Component @ConfigurationProperties(prefix = "sky.jwt") @Data public class JwtProperties {
private String adminSecretKey; private long adminTtl; private String adminTokenName;
private String userSecretKey; private long userTtl; private String userTokenName;
}
|
3.创建jwtutill工具类,定义生成和解密的方法进行封装
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
| public class JwtUtil {
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
long expMillis = System.currentTimeMillis() + ttlMillis; Date exp = new Date(expMillis);
JwtBuilder builder = Jwts.builder() .setClaims(claims) .signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8)) .setExpiration(exp);
return builder.compact(); }
public static Claims parseJWT(String secretKey, String token) { Claims claims = Jwts.parser() .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token).getBody(); return claims; }
}
|
4.在controller包下定义拦截器包书写拦截器类,对相关请求进行拦截校验jwt令牌
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
| @Component @Slf4j public class JwtTokenAdminInterceptor implements HandlerInterceptor {
@Autowired private JwtProperties jwtProperties;
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; }
String token = request.getHeader(jwtProperties.getAdminTokenName());
try { log.info("jwt校验:{}", token); Claims claims = JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token); Long empId = Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString()); log.info("当前员工id:", empId); BaseContext.setCurrentId(empId); return true; } catch (Exception ex) { response.setStatus(401); return false; } } }
|
配置相关拦截器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Configuration @Slf4j public class WebMvcConfiguration extends WebMvcConfigurationSupport { @Autowired private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
@Autowired private JwtTokenUserInterceptor jwtTokenUserInterceptor;
protected void addInterceptors(InterceptorRegistry registry){ log.info("开始注册拦截器"); registry.addInterceptor(jwtTokenAdminInterceptor) .addPathPatterns("/admin/**") .excludePathPatterns("/admin/employee/login"); registry.addInterceptor(jwtTokenUserInterceptor) .addPathPatterns("/user/**") .excludePathPatterns("/user/user/login") .excludePathPatterns("/user/shop/status"); }
|
5.使用时例如员工登陆功能,员工登陆,登陆成功后,生成jwt令牌,封装进vo对象的token属性中,返回给前端;后续的请求头都携带上这个token
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
| @RestController @RequestMapping("/admin/employee") @Slf4j public class EmployeeController { @Autowired private EmployeeService employeeService; @Autowired private JwtProperties jwtProperties;
@PostMapping("/login") public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) throws LoginException { log.info("员工登录: {}", employeeLoginDTO); Employee employee=employeeService.login(employeeLoginDTO); HashMap<String, Object> claims = new HashMap<>(); claims.put(JwtClaimsConstant.EMP_ID,employee.getId()); String token = JwtUtil.createJWT( jwtProperties.getAdminSecretKey(), jwtProperties.getAdminTtl(), claims);
EmployeeLoginVO employeeLoginVO=EmployeeLoginVO.builder() .id(employee.getId()) .name(employee.getName()) .userName(employee.getUsername()) .token(token) .build();
return Result.success(employeeLoginVO); }
|
2.时间转换器
1.写一个一个基于 Jackson 库的自定义对象映射器,添加时间相关的序列化和反序列化格式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 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(); 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(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); } }
|
2.配置类中注册
1 2 3 4 5 6
| protected void extendMessageConverters(List<HttpMessageConverter<?>> converters){ MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter(); converter.setObjectMapper(new JacksonObjectMapper()); converters.add(0,converter); }
|
3.Redis缓存优化
小程序菜品是通过数据库获得的,如果短时间大量人访问,会造成数据库短时间压力增大;
所以使用Redis对菜品做缓存;优先查询数据库,查询不到再查询数据库,并重建缓存;
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
| @RestController("userDishController") @RequestMapping("/user/dish") @Slf4j @Api(tags = "C端-菜品浏览接口") public class DishController { @Autowired private DishService dishService; @Autowired private RedisTemplate redisTemplate;
@GetMapping("/list") @ApiOperation("根据分类id查询菜品") public Result<List<DishVO>> list(Long categoryId) { String key = "dish_" + categoryId; List<DishVO> list = (List<DishVO>) redisTemplate.opsForValue().get(key); if (list != null && list.size() > 0) { return Result.success(list); }
Dish dish = new Dish(); dish.setCategoryId(categoryId); dish.setStatus(StatusConstant.ENABLE); list = dishService.listWithFlavor(dish); redisTemplate.opsForValue().set(key, list); return Result.success(list); }
}
|
4.HttpClient
HttpClient 是一个用于发送 HTTP 请求、接收 HTTP 响应的编程工具库;
小程序用户登录时需要进行后端校验, 后端需要通过HttpClient 调用微信官方的接口(auth.code2Session);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 小程序登录 │ ▼ 获取 code │ ▼ 发送给后端 │ ▼ 后端使用 HttpClient 调用微信接口 │ ▼ 获取 openid │ ▼ 生成 JWT token
|
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
| @Service public class UserServiceImpl implements UserService { public static final String WX_LOGIN="https://api.weixin.qq.com/sns/jscode2session"; @Autowired private WeChatProperties weChatProperties; @Autowired private UserMapper userMapper; @Override public User WxLogin(UserLoginDTO userLoginDTO) { Map<String,String> map =new HashMap<>(); map.put("appid",weChatProperties.getAppid()); map.put("secret",weChatProperties.getSecret()); map.put("js_code",userLoginDTO.getCode()); map.put("grant_type","authorization_code"); String json = HttpClientUtil.doGet(WX_LOGIN, map);
JSONObject jsonObject = JSON.parseObject(json); String openid = jsonObject.getString("openid");
if(openid ==null){ throw new LoginFailedException(MessageConstant.LOGIN_FAILED); }
User user=userMapper.getByOpenid(openid);
if (user == null){ user = User.builder() .openid(openid) .createTime(LocalDateTime.now()) .build(); userMapper.insert(user); }
return user; } }
|
5.购物车模块
购物车模块主要有新增,查询,和清空购物车的操作
新增时,会先判断菜品是否存在,存在则菜品的数量加一,不存在则插入菜品数据
在 苍穹外卖 中,购物车的设计是:每一种菜品(或套餐)对应一个购物车对象,但数量通过 number 字段累加
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
| @Service @Slf4j public class ShoppingCartServiceImpl implements ShoppingCartService { @Autowired private ShoppingCartMapper shoppingCartMapper; @Autowired private DishMapper dishMapper; @Autowired private SetmealMapper setmealMapper;
@Override public void addShoppingCart(ShoppingCartDTO shoppingCartDTO) {
ShoppingCart shoppingCart=new ShoppingCart(); BeanUtils.copyProperties(shoppingCartDTO,shoppingCart); Long currentId = BaseContext.getCurrentId(); shoppingCart.setUserId(currentId);
List<ShoppingCart> list = shoppingCartMapper.list(shoppingCart);
if(list != null && list.size()>0){ ShoppingCart cart=list.get(0); cart.setNumber(cart.getNumber()+1); shoppingCartMapper.updateNumberById(cart); }else{
Long dishId= shoppingCart.getDishId(); if(dishId != null){ Dish dish=dishMapper.getById(dishId); shoppingCart.setName(dish.getName()); shoppingCart.setImage(dish.getImage()); shoppingCart.setAmount(dish.getPrice()); shoppingCart.setNumber(1); shoppingCart.setCreateTime(LocalDateTime.now()); }else { Long setmealId = shoppingCart.getSetmealId(); Setmeal setmeal=setmealMapper.getById(setmealId); shoppingCart.setName(setmeal.getName()); shoppingCart.setImage(setmeal.getImage()); shoppingCart.setAmount(setmeal.getPrice()); shoppingCart.setNumber(1); shoppingCart.setCreateTime(LocalDateTime.now()); } shoppingCartMapper.insert(shoppingCart); } }
|
6.WebSocket
为了实现来单提醒功能,需要使用WebSocket
WebSocket是一种基于Tcp协议的一种新的网络协议;实现了浏览器和服务器的全双工通信—-浏览器服务器只需要一次握手,两者就能实现一个长连接,并进行双向数据传输;
Q1: 讲一下Tcp/ip协议;
Q2: 讲一下三次握手,四次挥手;
Q3: 为什么不是两次握手,为什么不是四次握手? 四次挥手中间两次挥手能不能合并为一次?
R1: Tcp/ip协议规定了 计算机在网络中如何进行数据传输和通信;
R2: 三次握手是为了确认双方通信能力、同步序列号、建立可靠连接, 在Tcp协议中,信息是通过报文传输的;

第一次握手,客户端向服务器发送一个SYN=1表示想要建立连接,和对应的seq; 这一步是为了(确保客户端有发送的能力),第二次握手,服务器接收到向客户端发送一个SYN=1,和ACK=1,表示同意也想建立连接,并发送对应的seq和ack(确保服务端有接收,发送的能力)第三次握手,客户端向服务器发送ACK=1,并发送对应的ack(确保客户端有接收的能力)
四次挥手:

第一次挥手,客户端向服务器端发送请求,表示想要断开连接; 第二次挥手,服务器端向客户端,发送确认信号; (这个时候连接是没有断开的) 第三次挥手服务器向客户端发送请求,表示想要断开连接; 第四次挥手客户端向服务器端发送,确认信号 (至此连接断开)
R3:为什么不是两次握手,四次握手: 因为三次握手是最少的能建立连接的握手次数,第三次连接,是为了让服务器知道客户端有接收消息的能力;
四次挥手中间两次挥手能不能合并为一次: 不能,服务器端发送确认号,这时候连接并不能断开,因为可能服务器还有信息没有发送完,服务器需要一段时间发送完信息之后再发起断开连接请求断开连接;
1.配置类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
public class WebSocketConfiguration {
@Bean @ConditionalOnWebApplication public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); }
}
|
2.创建 WebSocketServer,用于管理连接
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
|
@Component @ServerEndpoint("/ws/{sid}") public class WebSocketServer {
private static Map<String, Session> sessionMap = new HashMap();
@OnOpen public void onOpen(Session session, @PathParam("sid") String sid) { System.out.println("客户端:" + sid + "建立连接"); sessionMap.put(sid, session); }
@OnMessage public void onMessage(String message, @PathParam("sid") String sid) { System.out.println("收到来自客户端:" + sid + "的信息:" + message); }
@OnClose public void onClose(@PathParam("sid") String sid) { System.out.println("连接断开:" + sid); sessionMap.remove(sid); }
public void sendToAllClient(String message) { Collection<Session> sessions = sessionMap.values(); for (Session session : sessions) { try { session.getBasicRemote().sendText(message); } catch (Exception e) { e.printStackTrace(); } } }
}
|
3.业务中使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Autowired private WebSocketServer webSocketServer;
@Override public void reminder(Long id) { Orders orders=new Orders(); if(orders == null){ throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR); } Map map=new HashMap(); map.put("type",2); map.put("orderId",id); map.put("content","您的订单号为"+orders.getNumber()); webSocketServer.sendToAllClient(JSON.toJSONString(map)); }
|