• Java SpringBoot VII


    Java SpringBoot VII

    1. 使用Lombok框架

    在编写POJO类型(包括实体类、VO、DTO等)时,都有统一的编码规范,例如:

    • 属性都是私有的
    • 所有属性都有对应的Setter & Getter方法
    • 应该重写equals()hashCode()方法,以保证:如果2个对象的字面值完全相同,则equals()对比结果为true,且hashCode()返回值相同,如果2个对象的字面值不相同,则equals()对比结果为false,且hashCode()返回值不同
    • 实现Serializable接口

    另外,为了便于观察对象的各属性值,通常还会重写toString()方法。

    由于以上操作方式非常固定,且涉及的代码量虽然不难,但是篇幅较长,并且,当类中的属性需要修改时(包括修改原有属性、或增加新属性、删除原有属性),对应的其它方法都需要修改(或重新生成),管理起来比较麻烦。

    使用Lombok框架可以极大的简化这些操作,此框架可以通过注解的方式,在编译期来生成Setters & Getters、equals()hashCode()toString(),甚至生成构造方法等,所以,一旦使用此框架,开发人员就只需要在类中声明各属性、实现Serializable、添加Lombok指定的注解即可。

    在Spring Boot中,添加Lombok依赖,可以在创建项目时勾选,也可以后期自行添加,依赖项的代码为:

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    完成后,在各POJO类型中,将不再需要在源代码添加Setters & Getters、equals()hashCode()toString()这些方法,只需要在POJO类上添加@Data注解即可!

    当添加@Data注解,且删除相关方法后,由于源代码中没有相关方法,则调用了相关代码的方法可能会报错,但是,并不影响程序运行!

    为了避免IntelliJ IDEA判断失误而提示了警告和错误,推荐安装Lombok插件,可参考:

    • http://doc.canglaoshi.org/doc/idea_lombok/IDEA-5-PLUGINS-LOMBOK.html

    再次提示:无论是否安装插件,都不影响代码的编写和运行!

    2. Slf4j日志框架

    在开发实践中,不允许使用System.out.println()或类似的输出语句来输出显示关键数据(核心数据、敏感数据等),因为,如果是这样使用,无论是在开发环境,还是测试环境,还是生产环境中,这些输出语句都将输出相关信息,而删除或添加这些输出语句的操作成本比较高,操作可行性低。

    推荐的做法是使用日志框架来输出相关信息!

    当添加了Lombok依赖后,可以在需要使用日志的类上添加@Slf4j注解,然后,在类的任意中,均可使用名为log的变量,且调用其方法来输出日志(名为log的变量也是Lombok框架在编译期自动补充的声明并创建对象)!

    在Slf4j日志框架中,将日志的可显示级别根据其重要程度(严重程度)由低到高分为:

    • trace:跟踪信息
    • debug:调试信息
    • info:一般信息,通常不涉及关键流程和敏感数据
    • warn:警告信息,通常代码可以运行,但不够完美,或不规范
    • error:错误信息

    在配置文件中,可以通过logging.level.包名.类名来设置当前类的日志显示级别,例如:

    logging.level.cn.tedu.boot.demo.service.impl.AdminServiceImpl: info
    
    • 1

    当设置了显示的日志级别后,仅显示设置级别和更重要的级别的日志,例如,设置为info时,只显示infowarnerror,不会显示debugtrace级别的日志!

    当输出日志时,通过log变量调用trace()方法输出的日志就是trace级别的,调用debug()方法输出的日志就是debug()级别的,以此类推,可调用的方法还有info()warn()error()

    在开发实践中,关键数据和敏感数据都应该通过trace()debug()进行输出,在开发环境中,可以将日志的显示级别设置为trace,则会显示所有日志,当需要交付到生产环境中时,只需要将日志的显示级别调整为info即可!

    默认情况下,日志的显示级别是info,所以,即使没有在配置文件中进行正确的配置,所有info、warn、error级别的日志都会输出显示。

    在配置时,属性名称中的logging.level部分是必须的,在其后,必须写至少1级包名,例如:

    logging.level.cn: trace
    
    • 1

    以上配置表示cn包及其子孙包下的所有类中的日志都按照trace级别进行显示!

    在开发实践中,属性名称通常配置为logging.level.项目根包,例如:

    logging.level.cn.tedu.boot.demo: trace
    
    • 1

    在使用Slf4j时,通过log调用的每种级别的方法都被重载了多次(各级别对应除了方法名称不同,重载的次数和参数列表均相同),推荐使用的方法是参数列表为(String format, Object... arguments)的,例如:

    public void trace(String format, Object... arguments);
    public void debug(String format, Object... arguments);
    public void info(String format, Object... arguments);
    public void warn(String format, Object... arguments);
    public void error(String format, Object... arguments);
    
    • 1
    • 2
    • 3
    • 4
    • 5

    以上方法中,第1个参数是将要输出的字符串的模式(模版),在此字符串中,如果需要包含某个变量值,则使用{}表示,如果有多个变量值,均是如此,然后,再通过第2个参数(是可变参数)依次表示各{}对应的值,例如:

    log.debug("加密前的密码:{},加密后的密码:{}", password, encodedPassword);
    
    • 1

    使用这种做法,可以避免多变量时频繁的拼接字符串,另外,日志框架会将第1个参数进行缓存,以此提高后续每一次的执行效率。

    在开发实践中,应该对程序执行关键位置添加日志的输出,通常包括:

    • 每个方法的第1行有效语句,表示代码已经执行到此方法内,或此方法已经被成功调用
      • 如果方法是有参数的,还应该输出参数的值
    • 关键数据或核心数据在改变之前和之后
      • 例如对密码加密时,应该通过日志输出加密前和加密后的密码
    • 重要的操作执行之前
      • 例如尝试插入数据之前、修改数据之前,应该通过日志输出相关值
    • 程序走到某些重要的分支时
      • 例如经过判断,走向抛出异常之前

    其实,Slf4j日志框架只是日志的一种标准,并不是具体的实现(感觉上与Java中的接口有点相似),常见有具体实现了日志功能的框架有log4j、logback等,为了统一标准,所以才出现了Slf4j,同时,由于log4j、logback等框架实现功能并不统一,所以,Slf4j提供了对主流日志框架的兼容,在Spring Boot工程中,spring-boot-starter就已经依赖了spring-boot-starter-logging,而在此依赖下,通常包括Slf4j、具体的日志框架、Slf4j对具体日志框架的兼容。

    3. 密码加密

    【这并不是Spring Boot框架的知识点】

    对密码进行加密,可以有效的保障密码安全,即使出现数据库泄密,密码安全也不会受到影响!为了实现此目标,需要在对密码进行加密时,使用不可逆的算法进行处理!

    通常,不可以使用加密算法对密码进行加密码处理,从严格定义上来看,所有的加密算法都是可以逆向运算的,即同时存在加密和解密这2种操作,加密算法只能用于保证传输过程的安全,并不应该用于保证需要存储下来的密码的安全!

    哈希算法都是不可逆的,通常,用于处理密码加密的算法中,典型的是一些消息摘要算法,例如MD5、SHA256或以上位数的算法。

    消息摘要算法的主要特征有:

    • 消息相同时,摘要一定相同
    • 某种算法,无论消息长度多少,摘要的长度是固定的
    • 消息不同时,摘要几乎不会相同

    在消息摘要算法中,以MD5为例,其运算结果是一个128位长度的二进制数,通常会转换成十六进制数显示,所以是32位长度的十六进制数,MD5也被称之为128位算法。理论上,会存在2的128次方种类的摘要结果,且对应2的128次方种不同的消息,如果在未超过2的128次方种消息中,存在2个或多个不同的消息对应了相同的摘要,则称之为:发生了碰撞。一个消息摘要算法是否安全,取决其实际的碰撞概率,关于消息摘要算法的破解,也是研究其碰撞概率。

    存在穷举消息和摘要的对应关系,并利用摘要在此对应关系进行查询,从而得知消息的做法,但是,由于MD5是128位算法,全部穷举是不可能实现的,所以,只要原始密码(消息)足够复杂,就不会被收录到所记录的对应关系中去!

    为了进一步提高密码的安全性,在使用消息摘要算法进行处理时,通常还会加盐!盐值可以是任意的字符串,用于与密码一起作为被消息摘要算法运算的数据即可,例如:

    @Test
    public void md5Test() {
        String rawPassword = "123456";
        String salt = "kjfcsddkjfdsajfdiusf8743urf";
        String encodedPassword = DigestUtils.md5DigestAsHex(
                (salt + salt + rawPassword + salt + salt).getBytes());
        System.out.println("原密码:" + rawPassword);
        System.out.println("加密后的密码:" + encodedPassword);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    加盐的目的是使得被运算数据变得更加复杂,盐值本身和用法并没有明确要求!

    甚至,在某些用法或算法中,还会使用随机的盐值,则可以使用完全相同的原消息对应的摘要却不同!

    推荐了解:预计算的哈希链、彩虹表、雪花算法。

    为了进一步保证密码安全,还可以使用多重加密,即反复调用消息摘要算法。

    除此以外,还可以使用安全系数更高的算法,例如SHA-256是256位算法,SHA-384是384位算法,SHA-512是512位算法。

    一般的应用方式可以是:

    public class PasswordEncoder {
    
        public String encode(String rawPassword) {
            // 加密过程
            // 1. 使用MD5算法
            // 2. 使用随机的盐值
            // 3. 循环5次
            // 4. 盐的处理方式为:盐 + 原密码 + 盐 + 原密码 + 盐
            // 注意:因为使用了随机盐,盐值必须被记录下来,本次的返回结果使用$分隔盐与密文
            String salt = UUID.randomUUID().toString().replace("-", "");
            String encodedPassword = rawPassword;
            for (int i = 0; i < 5; i++) {
                encodedPassword = DigestUtils.md5DigestAsHex(
                        (salt + encodedPassword + salt + encodedPassword + salt).getBytes());
            }
            return salt + encodedPassword;
        }
    
        public boolean matches(String rawPassword, String encodedPassword) {
            String salt = encodedPassword.substring(0, 32);
            String newPassword = rawPassword;
                for (int i = 0; i < 5; i++) {
                    newPassword = DigestUtils.md5DigestAsHex(
                            (salt + newPassword + salt + newPassword + salt).getBytes());
            }
            newPassword = salt + newPassword;
            return newPassword.equals(encodedPassword);
        }
    
    }
    
    • 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

    4. 控制器层开发

    Spring MVC是用于处理控制器层开发的,在使用Spring Boot时,在pom.xml中添加spring-boot-starter-web即可整合Spring MVC框架及相关的常用依赖项(包含jackson-databind),可以将已存在的spring-boot-starter直接改为spring-boot-starter-web,因为在spring-boot-starter-web中已经包含了spring-boot-starter

    先在项目的根包下创建controller子包,并在此子包下创建AdminController,此类应该添加@RestController@RequestMapping(value = "/admins", produces = "application/json; charset=utf-8")注解,例如:

    @RestController
    @RequestMapping(values = "/admins", produces = "application/json; charset=utf-8")
    public class AdminController {
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    由于已经决定了服务器端响应时,将响应JSON格式的字符串,为保证能够响应JSON格式的结果,处理请求的方法返回值应该是自定义的数据类型,则从此前学习的spring-mvc项目中找到JsonResult类及相关类型,复制到当前项目中来。

    接下来,即可在AdminController中添加处理“增加管理员”的请求:

    @Autowired
    private IAdminService adminService;
    
    // 注意:暂时使用@RequestMapping,不要使用@PostMapping,以便于直接在浏览器中测试
    // http://localhost:8080/admins/add-new?username=root&password=1234
    @RequestMapping("/add-new") 
    public JsonResult<Void> addNew(AdminAddNewDTO adminAddNewDTO) {
        adminService.addNew(adminAddNewDTO);
        return JsonResult.ok();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    完成后,运行启动类,即可启动整个项目,在spring-boot-starter-web中,包含了Tomcat的依赖项,在启动时,会自动将当前项目打包并部署到此Tomcat上,所以,执行启动类时,会执行此Tomcat,同时,因为是内置的Tomcat,只为当前项目服务,所以,在将项目部署到Tomcat时,默认已经将Context Path(例如spring_mvc_war_exploded)配置为空字符串,所以,在启动项目后,访问的URL中并没有此前遇到的Context Path值。

    当项目启动成功后,即可在浏览器的地址栏中输入网址进行测试访问!

    注意:如果是未添加的管理员账号,可以成功执行结束,如果管理员账号已经存在,由于尚未处理异常,会提示500错误。

    关于处理异常,应该先在State中确保有每种异常对应的枚举值,例如本次需要补充InsertException对应的枚举值:

    public enum State {
    
        OK(200),
        ERR_USERNAME(201),
        ERR_PASSWORD(202),
        ERR_INSERT(500); // 新增的枚举值
    
        // 原有其它代码
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    然后,在cn.tedu.boot.demo.controller下创建handler.GlobalExceptionHandler类,用于统一处理异常,例如:

    package cn.tedu.boot.demo.controller.handler;
    
    import cn.tedu.boot.demo.ex.ServiceException;
    import cn.tedu.boot.demo.ex.UsernameDuplicateException;
    import cn.tedu.boot.demo.web.JsonResult;
    import cn.tedu.boot.demo.web.State;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.RestControllerAdvice;
    
    @RestControllerAdvice
    public class GlobalExceptionHandler {
    
        @ExceptionHandler(ServiceException.class)
        public JsonResult<Void> handleServiceException(ServiceException e) {
            if (e instanceof UsernameDuplicateException) {
                return JsonResult.fail(State.ERR_USERNAME, "用户名错误!");
            } else {
                return JsonResult.fail(State.ERR_INSERT, "插入数据失败!");
            }
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    完成后,重新启动项目,当添加管理员时的用户名没有被占用时,将正常添加,当用户名已经被占用时,会根据处理异常的结果进行响应!

    由于在统一处理异常的机制下,同一种异常,无论是在哪种业务中出现,处理异常时的描述信息都是完全相同的,也无法精准的表达错误信息,这是不合适的!另外,基于面向对象的“分工”思想,关于错误信息(异常对应的描述信息),应该是由Service来描述,即“谁抛出谁描述”,因为抛出异常的代码片段是最了解、最明确出现异常的原因的!

    为了更好的描述异常的原因,应该在自定义的ServiceException和其子孙类异常中添加基于父类的全部构造方法(5个),然后,在AdminServiceImpl中,当抛出异常时,可以在异常的构造方法中添加String类型的参数,对异常发生的原因进行描述,例如:

    @Override
    public void addNew(AdminAddNewDTO adminAddNewDTO) {
        // ===== 原有其它代码 =====
        
        // 判断查询结果是否不为null
        if (queryResult != null) {
            // 是:表示用户名已经被占用,则抛出UsernameDuplicateException
            log.error("此账号已经被占用,将抛出异常");
            throw new UsernameDuplicateException("添加管理员失败,用户名(" + username + ")已经被占用!");
        }
    
        // ===== 原有其它代码 =====
    
        // 判断以上返回的结果是否不为1,抛出InsertException异常
        if (rows != 1) {
            throw new InsertException("添加管理员失败,服务器忙,请稍后再次尝试!");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    最后,在处理异常时,可以调用异常对象的getMessage()方法获取抛出时封装的描述信息,例如:

    @ExceptionHandler(ServiceException.class)
    public JsonResult<Void> handleServiceException(ServiceException e) {
        if (e instanceof UsernameDuplicateException) {
            return JsonResult.fail(State.ERR_USERNAME, e.getMessage());
        } else {
            return JsonResult.fail(State.ERR_INSERT, e.getMessage());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    完成后,再次重启项目,当用户名已经存在时,可以显示在Service中描述的错误信息!

    最后,当添加成功时,响应的JSON数据例如:

    {
        "state":200,
        "message":null,
        "data":null
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    当用户名冲突,添加失败时,响应的JSON数据例如:

    {
        "state":201,
        "message":"添加管理员失败,用户名(liuguobin)已经被占用!",
        "data":null
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    可以看到,无论是成功还是失败,响应的JSON中都包含了不必要的数据(为null的数据),这些数据属性是没有必要响应到客户端的,如果需要去除这些不必要的值,可以在对应的属性上使用注解进行配置,例如:

    @Data
    public class JsonResult<T> implements Serializable {
    
        // 状态码,例如:200
        private Integer state;
        // 消息,例如:"登录失败,用户名不存在"
        @JsonInclude(JsonInclude.Include.NON_NULL)
        private String message;
        // 数据
        @JsonInclude(JsonInclude.Include.NON_NULL)
        private T data;
        
        // ===== 原有其它代码 =====
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    则响应的JSON中只会包含不为null的部分。

    此注解还可以添加在类上,则作用于当前类中所有的属性,例如:

    @Data
    @JsonInclude(JsonInclude.Include.NON_NULL)
    public class JsonResult<T> implements Serializable {
    
        // ===== 原有其它代码 =====
        
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    即使添加在类上,也只对当前类的3个属性有效,后续,当响应某些数据时,data属性可能是用户、商品、订单等类型,这些类型的数据中为null的部分依然会被响应到客户端去,所以,还需要对这些类型也添加相同的注解配置!

    以上做法相对比较繁琐,可以在application.properties / application.yml中添加全局配置,则作用于当前项目中所有响应时涉及的类,例如在properties中配置为:

    spring.jackson.default-property-inclusion=non_null
    
    • 1

    yml中配置为:

    spring:
      jackson:
        default-property-inclusion: non_null
    
    • 1
    • 2
    • 3

    注意:当你需要在yml中添加以上配置时,前缀属性名可能已经存在,则不允许出现重复的前缀属性名,例如以下配置就是错误的:

    spring:
      profiles:
        active: dev
    spring: # 此处就出现了相同的前缀属性名,是错误的
      jackson:
        default-property-inclusion: non_null
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    正确的配置例如:

    spring:
      profiles:
        active: dev
      jackson:
        default-property-inclusion: non_null
    
    • 1
    • 2
    • 3
    • 4
    • 5

    最后,以上配置只是“默认”配置,如果在某些类型中还有不同的配置需求,仍可以在类或属性上通过@JsonInclude进行配置。

    我是将军;我一直都在,。!

  • 相关阅读:
    【微服务|Sentinel】Sentinel快速入门|构建镜像|启动控制台
    stm32cubemx图形化配置之 FreeRTOS选项中CMSIS_V1和CMSIS_V2的区别
    Python【list列表去重】
    如何处理”此 SAP 系统不是当前编辑对象的原始系统“的问题
    vue3使用element-plus
    打游戏的蓝牙耳机推荐哪一款?吃鸡蓝牙游戏耳机推荐
    【元胞自动机】基于元胞自动机实现艺术图像处理附matlab代码
    Kubernetes学习笔记
    计算机网络第2章-HTTP和Web协议(2)
    ThingsBoard的版本控制整合gitee
  • 原文地址:https://blog.csdn.net/letterljhx/article/details/126919817