一字一句,带你读懂“外卖项目”

写在前面

起因是这样的,当我准备了很长时间八股文准备找一段实习工作时,我接到了蔚来的面试,面试官的一个问题让我大脑瞬间就宕机了——你这个项目是怎么实现序列化的?我回想起八股文中序列化是什么,在什么地方需要使用序列化,为什么不推荐使用JDK自带的序列化......可我的项目中到底那里使用了,怎么使用的序列化哪?

于是便有了今天这篇文章,我相信很多人像我一样,跟着某颜色的马敲了一遍外卖项目,但是到头来对这个项目却是一窍不通,现在如果抛开这个,让你自己设计一个项目,你会怎么做哪?你可能知道个大概,比如什么使用MVC架构、redis做缓存、MyBatis做持久化,可具体的细节你又要怎么实现?比如那部分需要做持久化,怎么做;要不要搞个统一的异常处理,怎么搞。那么下面便跟着我来一起梳理一下这整个外卖项目是怎么实现的,都做了哪些操作以及做这些操作的原因吧!

概述

首先我们可以看到整个项目分为了3层的结构,分布是:common,pojo,server,我先来讲解一下这3层的大致构造:

common:存放公共类,也就是项目中server层共同使用的部分,常见的比如String常量来定义的各种字段,下面是一个简单的例子:

public class MessageConstant {
    public static final String PASSWORD_ERROR = "密码错误";
    public static final String ACCOUNT_NOT_FOUND = "账号不存在";
    public static final String ACCOUNT_LOCKED = "账号被锁定";
    public static final String ALREADY_EXISTS="已存在";
}

通过这种方法就可以让我们在项目中直接使用英文字符而不需要打字,虽然这么看着有点脱裤子放屁,但是这种做法确实有一定道理,比如将来需要对提示信息之类进行修改时就不用到项目中去挨个找了,实现了一个解耦。

pojo:存放实体类,也就是普通java对象,MVC架构的model便对应这部分,这里有

  • dto(数据传输对象)数据在代码间互相传递而使用的对象,主要用于服务层和控制器层之间的数据传输,减少网络传输的数据量,提高性能。
  • entity(实体对象)也就是对应这数据库的哪些表和字段的部分
  • vo(视图对象)对应着在前端界面上显示的那些部分,封装了从后端传递到前端的数据。

为什么要分成这三个部分哪,直接使用一种对象怎么样?这也体现了一个解耦的好处,通过设置成3种不同的模块便让安全性和性能都有一定的优化,比如在使用VO来向前端传递数据时就可以把那些不必要的信息或者没有用的信息给过滤掉,传的少了响应速度变高了。至于在什么位置用了哪种对象我将在server层详细指出

serve:最核心的部分,整个后端的服务都在这里,包括什么员工,管理员登录;用AOP做的公共字段处理;JWT认证等等,大家如果是跟着视频来敲的话也多数是这部分

下面我便来详细讲解一下这3个部分都写了哪些内容:

common

先总览一下这部分都分为了哪些内容:

  1. constant:也就是前文所说的String常量,其中包括:公共字段填充相关常量,JWT令牌中使用到的常量,信息提示常量类,密码常量(其实只有一个默认密码123456),还有状态常量表示(启用禁用)
  2. context:这里面只定义了一个类用于线程空间ThreadLocal的管理,可以看到他把set,get方法都进行了封装,这便是一种规范化,如果直接调用不封装的话,谁知道你在里面存放的是什么东西,提一嘴这里面的ThreadLocal实际上存放的是解密后的jwt令牌,用来实现令牌校验优化的
  3. enumeration:使用了一个枚举类,使用枚举的地方还真不多这里应该是唯一处了,这个枚举只定义两个字段update,insert用来记录数据库操作类型
  4. exception:统一的异常处理部分,可以看这里面有非常多的类,但每个类只提供了2个方法,一个是无参构造,另一个有参构造方法(String类型的msg用来传递异常信息),每个类都继承了BaseException,而其又继承自RuntimeException,也就是对运行时异常的封装处理
  5. json:将java对象转为json和将json转为java对象的部分(也就是面试官问我你项目怎么实现序列化的部分,沟槽的原来在这里),此类继承 ObjectMapper,这是 Jackson 库中的一个核心类,用于 JSON 的序列化和反序列化。这里面定义了三个String静态常量,这种写法是对时间显示形式的规范定义方法;在构造方法中分为了3个部分:1. configure 方法, ObjectMapper 类提供的一个方法,用于配置 ObjectMapper 的行为,这里面的第一个参数实际上是父类提供的枚举值并非是我们定义的,第二个参便是告诉他不启用这个属性,既收到未知属性时不报异常;2. 同理设置为反序列化时的属性不存在兼容处理,解释一下这个this.xx是什么操作,这是指当前类的实例和对其使用的方法;3. 剩下的这一大串实际上是注册的一个自定义序列化模块,这个模块是对时间信息的序列化和反序列化,addSerializer 方法(序列化方法java对象转json)有两个参数:type:要序列化类型的class对象,serializer:自定义的序列化器实例,这里使用new来新建的。而addDeserializer 方法便是反序列化,最后将这个自定义的模块进行注册使其生效。通过这个部分便能实现前端的传过来的一个json数据要如何与java中的对象来做对应和如何把一个java对象转为json发给前端。在这里我们只是对时间和日期的格式进行了单独的处理,而其他部分Jackson所提供的功能已经足够满足我们使用了
  6. properties:这是用来做部分配置的,比如阿里的OSS,微信的小程序,还有jwt令牌相关,以jwt为例,这里用到了3个注解 @Component 和 @Data 这两个应该都懂吧,注册bean和生成set,get 那些 ,而@ConfigurationProperties指向了配置文件application.yml的jwt字段,这样的话便让String变量和文件中设置的属性相互对应也就是右边那个(多提一嘴,在go中通常是用viper来做这些的,结果现在才发现spring把这些都集成起来了真是挺强大的)
  7. result:分为了2个类,第一个类是对分页查询结果的封装,分别使用了@Date ,@AllArgsConstructor 和 @NoArgsConstructor 注解,用途为自动生成一个包含所有字段的构造方法和一个无参的构造方法。并且实现了 Serializable 接口,使该类的对象可以被序列化,便于在网络传输或持久化存储中使用。第二个类是对返回结果的统一处理,这里统一定义了Result<T>泛型类,其包含3个字段分别是Integer的code,String的msg和T的data,并提供了有参和无参的两个success方法和error方法,在server层的controller中,我们都是使用其作为返回值来表示是否执行成功并返回数据给前端。

utils:工具类

一共有4个类,其中阿里oss的请求文件上传等和微信小程序的服务部分这里不做展开,这里详细讲解一下jwt认证部分

public class JwtUtil {
    /**
     * 生成jwt
     * 使用Hs256算法, 私匙使用固定秘钥             《这部分是为了注释此方法的传入参数是什么等》
     * @param secretKey jwt秘钥
     * @param ttlMillis jwt过期时间(毫秒)
     * @param claims    设置的信息
     * @return
     */
    public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
        // 指定签名的时候使用的签名算法,也就是header那部分
        SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
        // 生成JWT的时间
        long expMillis = System.currentTimeMillis() + ttlMillis;
        Date exp = new Date(expMillis);
        // 设置jwt的body
        JwtBuilder builder = Jwts.builder()
                // 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
                .setClaims(claims)
                // 设置签名使用的签名算法和签名使用的秘钥
                .signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
                // 设置过期时间
                .setExpiration(exp);
        return builder.compact();
    }

虽然这段代码给出了注释但是我相信大部分人可能还有点困惑,这里解释一下,我们都知道Jwt由3部分组成:Header,Payload,Signature,在这里需要我们关心的是前两个,其中Header的签名算法的设置在11行(哈希256),而Payload中设置了claims(需要传递的信息),和exp(过期的时间),而第21行设置的秘钥并非写入Payload中,秘钥是保存在服务端的这里的方法调用是为了令其生成Signature。最后调用compact()将整个令牌作为String返回

public static Claims parseJWT(String secretKey, String token) {
        // 得到DefaultJwtParser
        Claims claims = Jwts.parser()
                // 设置签名的秘钥
                .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
                // 设置需要解析的jwt
                .parseClaimsJws(token).getBody();
        return claims;
    

令牌校验的部分就比较简单,通过秘钥就可以将token转成我们想要的数据了

pojo

这样可以明显看到dto的部分是最多的,在这个项目中我们使用了DTO用来从前端“接”数据,使用VO向前端“发”数据,而使用entity来封装/存放数据库数据

而在vo和entity中可以看到使用了大量的:@Data、@NoArgsConstructor 和 @AllArgsConstructor。通过这些注解可以帮助我们快速生产大量的构造方法,get/set方法。而@Builder可以让你通过链式方法来快速为对象赋值,比如我们返回结果为success时返回视图对象就使用这种方式创建并赋值

server

这一层是最多最复杂的部分,整体结构如下:

annotation:此处只定义了一个注解 AutoFill 用于标识哪些方法需要进行字段自动填充,如下@Target(ElementType.METHOD):表示该注解只能应用于方法上。@Retention(RetentionPolicy.RUNTIME):表示该注解在运行时保留,可以通过反射获取。而OperationType既上文所说的拥有两个字段的枚举类

aspect:这也就是项目中用到AOP的部分

这里使用的AOP主要作用就是当给数据库更新或插入数据时自动填充更新时间等数据

头部说明:可以看见此类使用了3个注解,其中

@Aspect:声明该类为一个切面类。

@Component:将该类注册为 Spring 容器中的一个 Bean。

@Slf4j:使用 Lombok 自动生成日志对象 log。

而通过@Pointcut注解将切入点定义为mapper包下带有@AutoFill注解(也就是上面自定义的注解)的方法,这样如果调用那些方法时便会自动在调用前执行下面的前置通知

前置通知:

@Before("autoFillPointCut()")//前置通知
    public void autoFill(JoinPoint joinPoint){
        log.info("开始进行公共字段自动填充...");//记录日志
        //获取当前被拦截方法上的注解对象
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();//方法签名对象
        AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);//获得方法上的注解对象
        //获取注解中定义的操作类型
        OperationType operationType = autoFill.value();
        Object[] args = joinPoint.getArgs();
        if (args.length == 0||args == null){
            return;
        }
        //获取当前被拦截方法参数的值-实体对象
        Object entity = args[0];
        //准备赋值数据
        LocalDateTime now=LocalDateTime.now();
        Long currentId = BaseContext.getCurrentId();

MethodSignature 是 Signature 的一个子接口,提供了更多关于方法的信息,如方法名、参数类型等,通过 MethodSignature 对象,我们可以获取到被拦截方法的详细信息。然后通过定义的autoFill实例来获得方法注解的对象,目的就是为了得到其标注的信息是insert还是update

接下来使用joinPoint.getArgs()来获取被拦截方法的第一个参数值,既需要填充字段的实体对象

        //根据当前不同的操作类型,为对应的属性通过反射赋值
        if(operationType == OperationType.INSERT){
            try {
                Method setUpdateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_USER, Long.class);
                Method setUpdateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_UPDATE_TIME, LocalDateTime.class);
                Method setCreateTime = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_TIME, LocalDateTime.class);
                Method setCreateUser = entity.getClass().getDeclaredMethod(AutoFillConstant.SET_CREATE_USER, Long.class);

                //通过反射为对象的属性赋值
                setCreateTime.invoke(entity,now);
                setCreateUser.invoke(entity,currentId);
                setUpdateTime.invoke(entity,now);
                setUpdateUser.invoke(entity,currentId);

            }catch (Exception e){
                e.printStackTrace();
            }
        }else if(operationType == OperationType.UPDATE) {

如果识别到注解上的为insert操作,则尝试用反射获取实体类中的set方法对象,并调用invoke方法(反射的方式,如果了解过反射的话一定对这段函数不陌生)稍微讲解一下,entity.getClass().getDeclaredMethod() 就是获得之前得到的需要填充字段的实体对象的Class对象,然后对class对象使用getDeclaredMethod()获得其中的方法(包括私有和公用的),两个参数分别为该方法的名称(这里的方法名写了一大堆,不用怀疑就是在common中定义的String常量)和其参数类型的class对象

有的人可能就好奇了这个setXX方法在哪里?我怎么没有找到,其实就在entity中比如Dish,这里就有创建/删除时间的定义,而他又使用了@Date注解,虽然看不到实际的方法但其实都自动生成好了

注解为更新的同理。

config:redis的配置和Mvc的配置

reids的配置部分,使用@Configuration:表示这是一个配置类(也是后文SpringCache生效的关键所在),Spring 容器会扫描这个类并加载其中的 Bean。接着对方法使用Bean注解交给Spring容器管理,之后的使用便可以直接注入

Mvc的配置部分:其中省略了通过knife4j(swagger)来生成接口文档的部分

@Configuration
@Slf4j
public class WebMvcConfiguration extends WebMvcConfigurationSupport {
    @Autowired
    private JwtTokenAdminInterceptor jwtTokenAdminInterceptor;
    @Autowired
    private JwtTokenUserInterceptor jwtTokenUserInterceptor;
    /**
     * 注册自定义拦截器
     * @param registry
     */
    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");
    }
    /**
     * 设置静态资源映射
     * @param registry
     */
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
        registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
    }
    //扩展MVC消息转换器
    protected void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        log.info("扩展消息转换器...");
        //创建消息转换器对象
        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
        //需要设置对象转换器,底层使用Jackson将Java对象转为json
        converter.setObjectMapper(new JacksonObjectMapper());
        //将消息转换器对象追加到MVC框架的转换器集合中
        converters.add(0,converter);
    }
}

这个仍旧使用了@Configuration注解,这样Spring 会自动扫描并加载该类中的配置,并且继承了 WebMvcConfigurationSupport类(对 Spring MVC 配置的扩展支持),包含两个自动注入的私有变量,这些类将在后面说明,用途为用户和员工的jwt令牌校验拦截器

  1. 第一个方法就是设置使用了何种拦截器对什么请求路径进行性拦截,这种链式方法调用可以视为一种固定用法。
  2. 第二个方法把比如url尾部为 /doc.html 的请求路径映射到 classpath:/META-INF/resources/ 目录下的 doc.html 文件,在这里的classpath 指JVM 在加载类和资源文件时搜索的路径。在 Maven中,classpath 通常包括:src/main/java:存放 Java 源代码。src/main/resources:存放资源文件,如配置文件、静态资源等。而这个项目中静态资源已经被放置了nginx中,项目中也没有META-INF文件包
  3. 第三个方法为消息转化器,这里便使用到了前文在common中所设定的JacksonObjectMapper(),json和java对象的转化器,这种设定方法就类似:1.声明一个东西,2.将自己的修改插入设定的东西中,3.让这个东西生效(可以看到在前面那么远声明的类在这个地方才得到使用,而且仅有这一个地方被使用过)

interceptor:jwt令牌校验拦截器

先讲一下上面使用到的这个包,这里面定义了两个类JwtTokenAdminInterceptor和JwtTokenUserInterceptor,这两个类其实大差不差,几乎是一样的内容只是区分一下用户和员工这两个部分

@Component
@Slf4j
public class JwtTokenAdminInterceptor implements HandlerInterceptor {
    @Autowired
    private JwtProperties jwtProperties;
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //判断当前拦截到的是Controller的方法还是其他资源
        if (!(handler instanceof HandlerMethod)) {
            //当前拦截到的不是动态方法,直接放行
            return true;
        }
        //1、从请求头中获取令牌
        String token = request.getHeader(jwtProperties.getAdminTokenName());
        //2、校验令牌
        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);
            //3、通过,放行
            return true;
        } catch (Exception ex) {
            //4、不通过,响应401状态码
            response.setStatus(401);
            return false;
        }
    }
}

这里自动注入了 JwtProperties 对象,也就是common中定义的那个,而preHandle方法 实现 HandlerInterceptor 接口中的 preHandle 方法,该方法在控制器方法执行之前调用。这部分比较简单,光看注解大概就可以理解,这里单独讲一下BaseContext,其实就是之前设置的ThreadLocal封装类,把这个id存入ThreadLocal中这样下次jwt就不需要解析可以直接进行比较,也就是一种优化jwt令牌校验的方式,其中jwt令牌中的信息通过JwtUtil.parseJWT方法(参数一个是秘钥,一个是token)得到并存放在claims中

controller:核心功能

    虽然说是核心功能但整体其实区别并不大,比如员工的增删改和用户或者菜品的都是同样的逻辑,所以这里先拿出一个典型例子员工管理来进行讲解

@RestController
@RequestMapping("/admin/employee")
@Slf4j
@Api(tags = "员工相关接口")
public class EmployeeController {
    @Autowired
    private EmployeeService employeeService;
    @Autowired
    private JwtProperties jwtProperties;
    @PostMapping("/login")
    @ApiOperation(value = "员工登录")
    public Result<EmployeeLoginVO> login(@RequestBody EmployeeLoginDTO employeeLoginDTO) {
        log.info("员工登录:{}", employeeLoginDTO);

        Employee employee = employeeService.login(employeeLoginDTO);

        //登录成功后,生成jwt令牌
        Map<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())
                .userName(employee.getUsername())
                .name(employee.getName())
                .token(token)
                .build();

        return Result.success(employeeLoginVO);
    }

此类使用了4个注解:@RestController:将该类标记为 RESTful 控制器,处理 HTTP 请求并返回 JSON 响应。@RequestMapping("/admin/employee"):定义该控制器的基路径为 /admin/employee,也就是说在此类下的方法的请求url都会自动加上这段(在go中使用类似的路由分组来实现)。@Api(tags = "员工相关接口"):使用 Swagger 注解,标记该控制器为“员工相关接口”。自动注入了2个类EmployeeService和JwtProperties。其中EmployeeService在后面的server中,这是一个接口实现类为EmployeeServiceImpl,这里使用接口来注入而不是实现类,是因为这样有着降低耦合度易于替换等好处。

login函数:可以看到这些函数使用了统一的Result作为返回值,这就是之前统一管理的结果,通过@PostMapping("/login")设置请求路径为/login,而参数(@RequestBody EmployeeLoginDTO employeeLoginDTO)为从请求体中获得的DTO对象,注解@RequestBody会将请求体中json转化为java对象并存放在DTO中,然后将DTO中的数据通过login方法转换为Employee对象,这个方法实际上是拿到username并根据这个name查询数据库得到的,同时也会校验密码是否正确。接下来是生成jwt的部分,首先明确创建jwt需要3个部分(可以看一下,我们之前的定义部分):密钥,过期时间,存放数据(Map<String,Object>类型)这里我们存放的是用户id。最后是创建视图对象部分,通过链式方法创建视图对象并赋值,最后将这个视图对象放在Result中返回给前端

    @PostMapping("/logout")
    @ApiOperation("员工退出")
    public Result<String> logout() {
        return Result.success();
    }
    @PostMapping
    @ApiOperation("新增员工")
    public Result save(@RequestBody EmployeeDTO employeeDTO){
        log.info("新增员工:{}",employeeDTO);
        employeeService.save(employeeDTO);
        return Result.success();
    }
    @GetMapping("/page")
    @ApiOperation("员工分页查询")
    public Result<PageResult> page(EmployeePageQueryDTO employeePageQueryDTO){
        log.info("员工分页查询,参数为:{}", employeePageQueryDTO);
        PageResult pageResult = employeeService.pageQuery(employeePageQueryDTO);
        return Result.success(pageResult);
    }

退出登录部分比较简单,只需要返回一个成功即可,新增员工也仅需调用一下save方法,而员工分页查询返回的为PageResult,包含总记录数和当前页数据,使用pageQuery方法得到

    @PostMapping("/status/{status}")
    @ApiOperation("启用禁用员工账号")
    public Result startOrStop(@PathVariable Integer status,Long id){
        log.info("启用禁用员工账号:{},{}",status,id);
        employeeService.startOrStop(status,id);
        return Result.success();
    }
    @GetMapping("/{id}")
    @ApiOperation("根据id查询员工信息")
    public Result<Employee> getById(@PathVariable Long id){
        Employee employee = employeeService.getById(id);
        return Result.success(employee);
    }
    @PutMapping
    @ApiOperation("编辑员工信息")
    public Result update(@RequestBody EmployeeDTO employeeDTO){
        log.info("编辑员工信息:{}", employeeDTO);
        employeeService.update(employeeDTO);
        return Result.success();
    }
}

这里比较特殊的是使用了 {x} 的方式,这会将请求路径中的路径变量赋值给status,例如请求 URL 为 /admin/employee/status/1?id=123,则 status为1 id 为 123。其余无特殊部分。

提一个比较特殊的部分,通用接口的文件上传功能,下面是代码和注释

@RestController
@RequestMapping("/admin/common")
@Api(tags = "通用接口")
@Slf4j
public class CommonController {
    @Autowired
    private AliOssUtil aliOssUtil;
    @PostMapping("/upload")
    @ApiOperation("文件上传")
    public Result<String> upload(MultipartFile file){
        log.info("文件上传:{}",file);
        try {
            //原始文件名
            String originalFilename = file.getOriginalFilename();
            //截取原始文件名的后缀   dfdfdf.png
            String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
            //构造新文件名称
            String objectName = UUID.randomUUID().toString() + extension;
            //文件的请求路径
            String filePath = aliOssUtil.upload(file.getBytes(), objectName);
            return Result.success(filePath);
        } catch (IOException e) {
            log.error("文件上传失败:{}", e);
        }
        return Result.error(MessageConstant.UPLOAD_FAILED);
    }
}

也是比较简单的。

handler:全局异常处理器

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    /**
     * 捕获业务异常
     * @param ex
     * @return
     */
    @ExceptionHandler
    public Result exceptionHandler(BaseException ex){
        log.error("异常信息:{}", ex.getMessage());
        return Result.error(ex.getMessage());
    }
    /**
     * 处理SQL异常
     * @param ex
     * @return
     */
    @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(" ");
            String username = split[2];
            String msg = username + MessageConstant.ALREADY_EXISTS;
            return Result.error(msg);
        }else{
            return Result.error(MessageConstant.UNKNOWN_ERROR);
        }
    }
}

注解: @RestControllerAdvice将当前类标记为一个全局异常处理器,适用于整个应用程序。@ExceptionHandler标记一个方法,使其成为一个异常处理器方法。当指定类型的异常在控制器方法中抛出时,Spring MVC 会调用该方法来处理异常。根据不同的参数实现的方法重载,当参数为上文的自定义错误类型时执行第一个方法,参数为SQL...错误时执行第二个,这里通过getMessage获得的异常信息通过空格分为不同的组,其中数组下标2对应的就是username然后把“用户名+已存在”作为错误信息,使用Result.error()方法返回

mapper:连接数据库部分

mapper部分也就是使用MyBatis与数据库连接的部分,MyBatis的使用方法这里不做详谈,同样使用员工部分来讲解

@Mapper
public interface EmployeeMapper {
    /**
     * 根据用户名查询员工
     * @param username
     * @return
     */
    @Select("select * from employee where username = #{username}")
    Employee getByUsername(String username);
    /**
     * 插入员工数据
     * @param employee
     */
    @Insert("insert into employee (name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user,status) " +
            "values " +
            "(#{name},#{username},#{password},#{phone},#{sex},#{idNumber},#{createTime},#{updateTime},#{createUser},#{updateUser},#{status})")
    @AutoFill(value = OperationType.INSERT)
    void insert(Employee employee);
    /**
     * 分页查询
     * @param employeePageQueryDTO
     * @return
     */
    Page<Employee> pageQuery(EmployeePageQueryDTO employeePageQueryDTO);
    /**
     * 根据主键动态修改属性
     * @param employee
     */
    @AutoFill(value = OperationType.UPDATE)
    void update(Employee employee);
    /**
     * 根据id查询员工信息
     * @param id
     * @return
     */
    @Select("select * from employee where id = #{id}")
    Employee getById(Long id);
}

首先一个Mapper注解起手,将当前接口标记为 MyBatis 的映射器接口,MyBatis 会自动扫描并创建其实现类。我们都知道对于简单的sql语句我们直接使用注解来声明,比如第一个select语句,根据name查询所有employee中的数据。而第二条插入语句使用了@AutoFill注解(我们之前定义那个)标注为insert,这样AOP便会自动帮我们填充公共字段。而第三个比较复杂的查询,我们将他写在resources中Mapper包下同名的xml中,如图

如果这种复杂的SQL语句如果写在一个注解里会非常难看,但我还是不太喜欢这种xml映射的方式,感觉比较麻烦,两个Mapper中的内容如下:

service:controller中方法的实现

我们可以看到整个server都分为了接口和实现类两部分:

项目开始时会给出我们接口的部分(这方面的设定是架构师的活?),讲解一下这部分,以employee为例:在Controller中我们自动注入了Service(会为我们自动寻找其实现类),然后使用其login()方法;而在Service中我们自动注入了Mapper,然后login方法中使用了Mapper的getByUsername()方法来查询数据库。也就是Controller中我们规定做什么,Service中我们规定怎么做,而Mapper中有我们具体操作数据库的细节。一个完整的业务流程链大概就是这样。

接下来我同样以用户为例子讲解一下:

@Service
public class EmployeeServiceImpl implements EmployeeService {
    @Autowired
    private EmployeeMapper employeeMapper;
    /**
     * 员工登录
     * @param employeeLoginDTO
     * @return
     */
    public Employee login(EmployeeLoginDTO employeeLoginDTO) {
        String username = employeeLoginDTO.getUsername();
        String password = employeeLoginDTO.getPassword();

        //1、根据用户名查询数据库中的数据
        Employee employee = employeeMapper.getByUsername(username);
        //2、处理各种异常情况(用户名不存在、密码不对、账号被锁定)
        if (employee == null) {
            //账号不存在
            throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
        }

        //密码比对
        //对前端传过来的明文密码进行md5加密处理
        password = DigestUtils.md5DigestAsHex(password.getBytes());
        if (!password.equals(employee.getPassword())) {
            //密码错误
            throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
        }
        if (employee.getStatus() == StatusConstant.DISABLE) {
            //账号被锁定
            throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
        }
        //3、返回实体对象
        return employee;
    }

@Service注解,标记此类作为服务层的组件,当 Spring 应用启动时,会自动扫描带有 @Service 注解的类,并将其注册为 Spring 容器中的一个 Bean。这样,Spring 容器就可以管理和控制这些类的生命周期,包括实例化、依赖注入等。

从这便可以看出,在项目中我们使用了DTO用来从前端“接”数据,使用VO向前端“发”数据,而使用entity来封装/存放数据库数据,而这部分代码不少但逻辑都比较简单就不再赘述了。

额外说一个比较细的,在这里

Controller中我们调用了Service中的同名函数getById(),在这里我们进行了一个操作,把employee的密码设置为了***,不要问我为什么。如果你还在好奇这么做难道不会影响到数据库吗,请你可以看Service中有没有使用到Mapper中方法(实际操作数据库的)。通过这个操作就让查询时不会显示这个员工的密码了,做到了一定的安全性。

补充

1.响应:

如果你使用过go的gin,一个简单http响应大概是这样的

如果你了解响应状态码的话,那么便可以看到这里用来表示响应成功的状态码“200”,那么为什么在这个项目中我们没有看到任何设置的响应状态码哪?这里就要提到SpringMVC中的注解@RestController,在controller中我们可以发现任何一个类都使用了这个注解,这个注解其实是@Controller@ResponseBody合二为一构成的,如果想将代码交给Spring管理我们需要使用@Controller这个注解,而想要返回数据需要使用@ResponseBody这个注解,在Spring中他可以根据我们返回的类型来自动转换比如:

  • 当我们的接口返回的是【String/Integer之类】的,content-Type是【text/html】
  • 当我们的接口返回的【是对象/Map之类】的,content-Type是【application/json】
  • 这个功能是非常强大的,在gin中我们需要手动设置各种返回类型比如返回一个json是这样的:

    而在此项目中,我们使用了在common中统一Result作为返回值,Spring会自动对Result进行类型转换,而这里面Result都为对象类型会将其转化为json返回给前端,至于响应状态码,如果spring执行成功会自动为我们设置为“200”,可以在前端看见。

    2.以套餐部分为例:

    在业务中套餐部分是相对较为特别的一个部分,主要原因有两个:1.我们选择将套餐数据放在缓存中 2.在其中我们使用了@Transactional注解来进行了一部分事务管理,同样使用事务的地方如下

    数据缓存

    先介绍一下SpringCache的常用注解:

    • @Cacheable:功能:用于标记一个方法,表示其返回结果可以被缓存。如果缓存中存在相同键的数据,则直接从缓存中读取,不再执行方法。
    • @CachePut:功能:用于标记一个方法,表示其返回结果会被添加到缓存中,但方法总会被执行
    • @CacheEvict:功能:用于标记一个方法,表示其执行后会清除缓存中的数据。
    • @Caching:功能:用于组合多个缓存操作,如 @Cacheable、@CachePut 和 @CacheEvict

    在controller的中admin,我们使用的注解如下

    而在controller的中user中,我们使用的注解如下

    你可能会问为什么在员工中我们在新增套餐时使用的是删除的方法,我一开始也比较困惑后来才反应过来,当员工新增一个套餐时,要先把缓存中存放的(该套餐的)套餐类别集合的数据清除,然后将这个套餐数据写入数据库。如果不这么做缓存中的套餐类别集合中就缺少了这个新增的套餐,而数据库中则是完整的。这个方式也就是“旁路缓存模式”。

    这时反观用户中,使用@Cacheable,便先去查询缓存中是否有想要的数据,如果没有就查询数据库然后再写入到缓冲之中,至于为什么对缓存和数据库要进行这样的操作可以去了解一下缓存读写策略中的旁路缓存模式。

    注意:缓存中不只有套餐数据,实际上也有菜品和店铺的一些数据,只不过这部分并没有使用@SpringCache注解来进行缓存操作,而是使用了redisTemplate来进行操作,为什么使用了两种不同的方式哪?很简单,这是一个教学项目,目的是教你方法而非完全规范,实际中使用注解方式可能会更加便捷,如果想要使用SpringCache需要一些必要步骤,比如在启动类添加@EnableCaching注解来表示开启缓存注解功能,在redis配置类中使用@Configuration注解并创建redis模版对象。想要详细了解可以学习一下这部分相关内容。

    事务管理

    开启事务同样需要在启动类中使用@EnableTransactionManagement注解来标识,使用地点在Service中

    简单来说,事务就是保证这一组操作中如果有一个地方失败便回滚,回到事务开始的状态。在这里,我们首先把dto中的对象值copy给了setmeal,然后在套餐表中插入数据再获得新生成的套餐id,而下面的这个lamda表达式是在套餐关联的菜品列表中为每一个菜品添加该套餐的id,也就是表示这个菜品属于这个套餐,这样当某个菜品添加失败时便可以回滚,而不会发生一部分添加成功另一部分失败的情况,保持了一定程度上的一致性。

    写在后面

    说实话这个项目很完备的,具有一定参考意义,我曾经看过很多简历上的外卖项目简介,写的内容既少又缺乏特色,回过头来看这个项目其实有很多优点来介绍,比如:在jwt鉴权时使用ThreadLocal进行了优化,使用自定义注解结合AOP来实现公共字段填充,对于序列化我在使用Jackson的基础上又额外做了哪些工作等等

    我之前听很多人说“外卖项目烂大街了”,但是真正烂大街的是那些只敲出了代码却对整个项目一无所知的人,什么网上书店,房屋租赁系统,图书管理系统等等,其实做的无非是一套东西,但重点不是你做了什么,而是你怎么做的。

    最后,这是我第一次写这么长的文章而本人实力也有限,如果有什么地方说错了还望大家指出,我会及时修改。祝愿大家可以找到一份自己满意的工作

    #简历中的项目经历要怎么写##我的成功项目解析##牛客解忧铺##面试中的破防瞬间##面试时最害怕被问到的问题#

    制作不易,给个订阅或收藏呗

    全部评论
    外卖仙人
    1 回复 分享
    发布于 11-24 08:57 湖南
    如果把每个细节都记录下来那学完后确实对项目理解更深,也方便自己回顾和发现漏洞或可优化之处,就是时间花费更多,不过确实是一种非常有效的学习方式,但外卖点评说实话脱离视频看接口文档手敲又有多少人能坚持下来呢?
    1 回复 分享
    发布于 11-24 12:37 广东
    学长写得太好了
    1 回复 分享
    发布于 11-25 22:42 安徽
    写的很好,就是现在找实习都要挖这么深了吗
    点赞 回复 分享
    发布于 11-23 20:19 浙江

    相关推荐

    44 149 评论
    分享
    牛客网
    牛客企业服务