Bootstrap

苍穹外卖模块代码涉及的技术问题总结(一)

苍穹外卖项目,目前正在二刷。
记录模块存在功能问题以及涉及技术加以总结,希望大家一起学习进步。😍 🥰 😘😍 🥰 😘😍 🥰 😘

开发环境搭建

问题:前端发送的请求,是如何请求到后端服务的?

前端请求地址:http://localhost/api/employee/login

后端接口地址:http://localhost:8080/admin/employee/login

原因:Nignx反向代理,将前端发送的动态请求由nignx转发到后端服务器

反向代理:是指代理服务器接收客户端的请求,然后将请求转发给后端服务器,并将后端服务器的响应 返回给客户端。反向代理隐藏了服务器的真实身份和位置信息,客户端只知道与反向代理进⾏通信,而不知道真正的服务器。

反向代理好处:

  • 提⾼访问速度:Nignx本身就可以进⾏缓存
  • 进⾏负载均衡:指定的⽅式均衡的分配给集群中的每台服务器
  • 保证后端服务安全

Nignx反向代理的配置方式:

server{
        listen 80;
        server name localhost;

        location /api/{
                proxy_pass http://localhost:8080/admin/;#反向代理
        }
}

Nignx负载均衡的配置方式:

upstream webservers{
        server 192.168.100.128:8080;
        server 192.168.100.129:8080;
}

server{
        listen 80;
        server name localhost;

        location /api/{
                    proxy_pass http://webservers/admin/;#负载均衡
        }
}

员工管理模块

新增员工

问题一:新增员工时用户名是唯一的,需要全局异常处理来捕获

@ExceptionHandler
public Result exceptionHandler(sQLIntegrityConstraintViolationException ex){
//Duplicate entry 'zhangsan'for key 'employee.idx_username
    String message =ex.getMessage();
    if(message.contains("Duplicate entry")){
        String[]split=message.split( regex:"");
        String username = split[2];
        String msg=username + MessageConstant.ALREADY_EXISTS;
        return Result.error(msg);
    }else{
        return Result.error(MessageConstant.UNKNOWN_ERROR);
    }
}

问题二:创建人以及更新人是动态的而不是唯一的,需要动态获取登录员工的id

第一步:登录成功之后生成JWT令牌

Map<string,object>claims=new HashMap<>();
claims.put(Iwtclaimsconstant.EMP_ID,employee.getId());
string token = Jwtutil.createJwT(
                                jwtProperties.getAdminsecretkey()
                                jwtProperties.getAdminTtl(),
                                claims);

第二步:通过JWT令牌解析出当前登录员工id

//从请求头中获取令牌
string token=request.getHeader(jwtProperties.getAdminTokenName());
//校验令牌
try{
    Claims claims = Jwtutil.parseIwT(iwtProperties.getAdminsecretKey(),token):
    Long empId = Long.value0f(claims.get(Iwtclaimsconstant.EMP_ID).tostring());
    BaseContext.setCurrentId(empId)
    //通过,放行
    return true;
}catch(Exception ex)
    //不通过,响应401状态码
    response.setstatus(401);
    return false;
}

第三步:解析出来登录员工id之后,传递给业务层的save方法

public class BaseContext{
    public static ThreadLocal<Long> threadLocal = new ThreadLocal<>()
    public static void setCurrentId(Long id){
        threadLocal.set(id);
    }
    public static Long getCurrentId(){
        return threadLocal.get();
    }        
    public static void removeCurrentId(){
        threadLocal.remove();
    }
}
public void save(EmployeeDTo employeeDTO){
    Employee employee =new Employee();
    //对象属性拷贝
    BeanUtils.copyProperties(employeeDTo,employee);
    //设置账号的状态,默认正常状态1表示正常 0表示锁定
    employee.setstatus(statusConstant.ENABLE);状态常量
    //设置密码,默认密码123456
    employee.setPassword(Digestutils.mdsDigestAsHex(PasswordConstant,DEFAULT_PASSWORD.getBytes()));
    //设置当前记录的创建时间和修改时间
    employee.setCreateTime(LocalDateTime.now());
    employee.setUpdateTime(LocalDateTime.now());
    //设置当前记录创建入id和修改人id
    // TOD0 后期需要改为当前登录用户的id
    employee.setCreateUser(BaseContext.getCurrentId());
    employee.setUpdateUser(BaseContext.getCurrentId());
}

ThreadLocal 为每⼀个线程提供⼀个单独的存储空间,具有线程隔离的作⽤,只有在同⼀个线程内才可以获得他的值,以保证线程安全。

ThreadLocal存储在多个⽅法中需要共享的数据,具体来说是项⽬中的⽤户id,其他⽅法需要调⽤这个参数,就不需要显示传递。ThreadLocal底层原理是通过⼀个ThreadLocalMap来实现的。在每⼀个线程中都有⼀个ThreadLocalMap,⽤于存储线程局部变量。ThreadLocalMap中的键是ThreadLocal对象,值是对应线程的变量副本。当⼀个线程需要获取变量值时,⾸先会获取⾃⼰线程的ThreadLocalMap,并根据ThreadLocal对象获取对应的变量副本。实现了每个现象都拥有⾃⼰独⽴的变量副本,互不影响。

员工分页查询

问题一:如何实现分页查询

PageHelper实现了⼀个PageInterceptor拦截器,Mybatis会加载这个拦截器到拦截器链中。在使⽤过程中先使⽤PageHelper.startPage()方法在当前线程上下⽂中设置⼀个ThreadLocal变量,再利 ⽤PageInterceptor分⻚拦截器拦截,从ThreadLocal中拿到分⻚的信息,如果有分⻚信息拼装分⻚SQL(limit语句等)语句进⾏分⻚查询,最后再把ThreadLocal中的东⻄清除掉。

问题二:时间返回的格式不符合需求

方法一:在属性上加入注解,对日期进行格式化

@JsonFormat(pattern ="yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime

方法二:在 WebMvcConfiguration 中扩展Spring MVC的消息转换器,统一对日期类型进行格式化处理

protected void extendMessageConverters(List<HttpMessageConverter<?>> converters){
    Log.info("开始扩展消息转换器..");
    
    //创建一个消息转化器对象
    
    MappingJackson2HttpMessageConverter convrter = new MappingJackson2HttpMessageconverter();
    
    //设置对象转换器,可以将]ava对象转为ison字符串
    
    converter.setobjectMapper(new JacksonobjectMapper());
    
    //将我们自己的转换器放入spring Mvc框架的容器中
    
    converters.add(0,converter);
}

公共自动填充问题(AOP、注解、反射、枚举)

存在问题:业务表中的出现的公共字段,导致程序中出现冗余的代码,后期变更维护不方便

解决方法:使⽤AOP切⾯编程,实现功能增强,完成公共字段⾃动填充功能

实现步骤:

1. ⾃定义注解AutoFill,⽤于标识需要进⾏公共字段⾃动填充的⽅法

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill{
// 数据库操作类型 枚举:UPDATE  INSERT
    OperationType value();
}

2. ⾃定义切⾯类AutoFillAspect,统⼀拦截加⼊了AutoFill注解的⽅法,通过反射为公共字段赋值

@Aspect
@Component
@slf4j
public class AutoFillAspect {
    //切入点
    @Pointcut("execution(* com.sky.mapper.*,*(..))&& @annotation(com.sky.annotation.AutoFill)")
    public void autoFillPointCut(){}

    //前置通知,在通知中进行公共字段的赋值
    @Before("autoFillPointCut()")
    public void adtoFill(JoinPoint joinPoint){
        log.info("开始进行公共字段自动填充...");
    //获取到当前被拦裁的方法上的数据库操作类型
    Methodsignature signature=(Methodsignature)joinPoint.getsignature();//方法然名对象
    AutoFill autoFi1l =signature.getMethod().getAnnotation(AutoFill.class);//我得方法上的解对象
    OperationType operationType =autoFill.value();//获得数据库操作类型
    //获取到当前被拦载的方法的参数--实体对象
    object[]args = joinPoint.getArgs();
    if(args =null||args.length .0){
        return;
    }
    object entity = args[0];
    //准备赋值的数据
    LocalDateTime now=LocalDateTime.now();
    Long currentId=BaseContext.getCurrentId();
    //根据当前不同的操作类型,为对应的属性通过反射来赋值
    if(operationType == OperationType.INSERT){
    //为4个公共字段赋值
        try {
            Method setCreateTime = entity.getclass().getDeclaredMethod( name: "setCreateTime", LocalDateTime.class)
            Method setCreateUser = entity.getClass().getDeclaredMethod( name:
"setCreateUser",Long.class);
            Method setUpdateTime = entity.getclass().getDeclaredMethod( name: "setUpdateTime", LocalDateTime.class)
            Method setUpdateUser = entity.getClass().getDeclaredMethod( name: "setUpdateUser", Long.class);
            //通过反射为对象属性赋值
            setCreateTime.invoke(entity,now);
            setCreateUser.invoke(entity,currentId);
            setUptateTime.invoke(entity,now);
            setUpdateUser.invoke(entity,currentId);
        catch(Exception e){
            e.printstackTrace();
        }
    }

3. 在Mapper的⽅法上加上AutoFill注解

AOP核心概念:

反射概念:

反射通过获取类的信息并动态调⽤⽅法、创建对象等。让程序能够在运⾏时根据需要动态地获取和操作类的结构和成员。

获取 Class 对象: 程序通过类的全限定名、对象的getClass ()⽅法或.Class 语法来获取对应的 Class 对象。

查询类信息: 通过 Class 对象可以获取类的信息,包括类名、包名、⽗类、实现的接⼝、构造函数、⽅法、字段等。

动态创建对象: 通过 Class 对象的 newInstance ()⽅法调⽤类的默认构造函数来创建对象,或者通过 Constructor 对象调⽤类的其他构造函数来创建对象。

动态调⽤⽅法: 通过 Method 对象调⽤类的⽅法,传递参数并获取返回值。

动态访问字段: 通过 Field 对象获取和设置类的字段值。 

店铺营业状态

Redis常用数据类型:字符串string、哈希hash、列表list、集合set、有序集合sorted set / zset

Spring Data Redis使用方法:

编写配置类,创建RedisTemplate,通过RedisTemplate对象操作Redis

@configuration
@slf4j
public class RedisConfiguration{
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionfactory){
    Log.info("开始创建redis模板类...");
    RedisTemplate redisTemplate=new RedisTemplate();
    // 设置key的序列化器,默认为IdkserializationRedisserializer
    redisTemplate.setKeySerinlizer(new stringRedisSerializer())
    redisTemplate.setConnectionFactory(redisConnectionFactory);
    return redisTemplate;
    }
}

问题:营业状态对于用户端是高频查询字段,

解决:营业状态数据存储方式基于Redis的字符串来进行存储,Redis将数据放到缓存中,减少高频查询给磁盘带来的压力

@PutMapping("/{status}")
@Apioperation("设置店铺的营业状态”)
public Result setstatus(@PathVariable Integer status){
    Log.info("设置唐铺的营业状态为:{}”,statusm1?“营业中”:“打烊中”);
    redisTemplate.opsForValue().set("SHOP_STATUs",status);
    return Result.success();
}

@GetMapping("/status")
@Apioperation("获取店铺的营业状态”)
public Result<Integer> getstatus(){
    Integer status = (Integer)redisTemplate,opsForValue().get("SHOP_STATUS");
    Log.info("获取到店铺的营业状态为:{}",status1?"营业中”:"打烊中");
    return Result.success(status);
}

微信登录

Httpclient:

Httpclient是⼀个服务器端进⾏ HTTP 通信的库,使得后端可以发送各种 HTTP 请求和接收 HTTP 响应,使⽤Httpclient,可以轻松的发送 GET, POST, PUT, DELETE 等各种类型的的请求。 在项⽬中,进⾏微信登录开发时,后端在使⽤登录凭证校验接⼝的时候就需要通过Httpclient发送指定请求到给定的URL中。

@PostMapping("/login")
@ApiOperation("微信登录")
public Result<UserLoginVO> login(@RequestBody UserLoginDTo userLoginDT0){
    Log.info("微信用户登录:{}",userLoginDT0.getcode());
    //微信登录
    User user = userService.wxLogin(userLoginDT0);
    //为微信用户生成jwt令牌
    Map<String,Object>claims =new HashMap<>();
    claims.put(IwtclaimsConstant.USER ID,user.getId());
    String token =     JwtUtil.create]wT(jwtProperties.getUserSecretKey(),jwtProperties.getUserTtl(), claims)
    UserLoginvo userLoginvo =tserLoginvo.builder()
                .id(user.getId())
                .openid(user.getOpenid())
                .token(token)
                .build();
    return Result.success(userLoginVO)
}
public User wxLogin(UserLoginDTo userLoginDT0){
    //调用微信接口服务,获得当前微信用户的openid
    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(key: "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;
    }
}
    

缓存菜品

存在问题:用户端小程序展示的菜品数据都是通过查询数据库获得,如果用户端访问比较大,数据库访问压力随之增大

解决问题:通过Redis缓存菜品数据,减少数据库查询操作

缓存逻辑分析:

  • 每个分类下的菜品保持一份缓存数据
  • 数据库中菜品数据有变更时清理缓存数据(新增、修改、批量删除、起售、停售菜品)
@GetMapping("/list")
@Api0peration("根据分类id査询菜品")
public Result<List<Dishvo>> list(Long categoryId){
    //构造redis中的key.规则:dish_分类id
    String key = "dish_" + categoryId;
    //査询redis中是否存在菜品数据
    List<Dishvo>list = (List<Dishvo>)redisTemplate.opsForValue().get(key);
    if(list !=null 88 list.size()>0){
        //如果存在、直按返回。无须查询数据库
        return Result.success(list);
    }

    Dish dish = new Dish();
    dish.setcategoryId(categoryId);
    dish.setstatus(statusconstant.ENABLE);//查询起售中的菜品
    //如果不存在、查询数据库、将查询到的数据放入redis中
    list = dishService.listwithFlavor(dish);
    redisTemplate.opsForValue().set(key, list);
    return Result.success(list);
}
@PostMapping
@ApiOperation("新增菜品")
public Result save(@RequestBody DishDTo dishDTo){
    log.info("新增菜品:{}",dishDTO);
    dishservice.savewithFlavor(dishDT0);
    //清理缓存数据    
    String key = "dish " + dishDTo.getCategoryId()
    cleanCache(key);
    return Result.success();
}

private void cleanCache(String pattern){
    Set keys = redisTemplate.keys(pattern)
    redisTemplate.delete(keys);
}

缓存套餐

Spring Cache:基于注解的缓存功能

实现思路:

导入Spring Cache和Redis相关maven坐标
在启动类上加入@EnableCaching注解,开启缓存注解功能
在用户端接口SetmealController的 list 方法上加入@Cacheable注解
在管理端接口SetmealController的 save、delete、update、startOrStop等方法上加入@CacheEvict注解

@GetMapping("/list")
@ApiOperation("根据分类id查询套餐")
@Cacheable(cacheNames="setmealCache",key="#categoryId")//key: setmealCache:: 100
public Result<List<Setmeal>>list(Long categoryId){
    Setmeal setmeal = new Setmeal();
    setmeal.setCategoryId(categoryId);
    setmeal.setstatus(statusConstant.ENABLE);
    List<Setmeal>list=setmealservice.list(setmeal);
    return Result.success(list);
}


//精确清理
@PostMapping
@ApiOperation("新增套餐")
@CacheEvict(cacheNames = "setmealCache",key= "#setmealDT0.categoryId")//key: setmealCache:: 100
public Result save(@RequestBody setmealDTo setmealDTo){
    setmealService.saveWithDish(setmealDTo);
    return Result.success();
}


//批量、更新、起售、停售需要清理所有缓存数据
@DeleteMapping
@ApiOperation("批量删除套餐")
@CacheEvict(cacheNames="setmealCache",allEntries = true)
public Result delete(@RequestParam List<Long>ids){
    setmealservice.deleteBatch(ids);
    return Result.success();
}