目录
项目创建
通用功能模块
错误码
自定义异常类
CommonResult
jackson
加密工具
项目创建
使用 Idea 创建 SpringBoot 项目,并引入相关依赖:
导入前端页面
前端页面:前端代码/在线抽奖系统 · Echo/project - 码云 - 开源中国
将其导入到 static 目录下:
通用功能模块
错误码
错误码主要用于标识和处理程序运行中的各种异常情况,能够精确的指出问题所在
创建 errorcode 包,并定义错误码类型:
@Data
public class ErrorCode {/*** 错误码*/private final Integer code;/*** 错误描述*/private final String message;public ErrorCode(Integer code, String message) {this.code = code;this.message = message;}
}
定义全局错误码:
public interface GlobalErrorCodeConstants {// 成功ErrorCode SUCCESS = new ErrorCode(200, "成功");ErrorCode BAD_REQUEST = new ErrorCode(400, "客户端请求错误");ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常");ErrorCode NOT_IMPLEMENTED = new ErrorCode(501, "功能未实现/未开启");ErrorCode ERROR_CONFIGURATION = new ErrorCode(502, "配置项错误");ErrorCode UNKNOWN = new ErrorCode(999, "未知错误");
}
定义 controller 层业务错误码:
public interface ControllerErrorCodeConstants {}
其中的错误码信息随着后续业务代码的完成补充
定义 service 层业务错误码:
public interface ServiceErrorCodeConstants {}
其中的错误码信息随着后续业务代码的完成补充
自定义异常类
自定义异常类是为了在程序中处理特定的错误或异常情境,使得异常处理更加清晰和灵活。通过自定义异常类,可以根据业务需求定义特定的异常类型,方便捕获和处理特定的错误
创建 exception 包,自定义异常类:
controller 层异常类:
@Data
@EqualsAndHashCode(callSuper = true)
public class ControllerException extends RuntimeException {/*** controller 层错误码* @see com.example.lotterysystem.common.errorcode.ControllerErrorCodeConstants*/private Integer code;/*** 错误描述信息*/private String message;/*** 无参构造方法,为了后续进行序列化*/public ControllerException() {}public ControllerException(Integer code, String message) {this.code = code;this.message = message;}public ControllerException(ErrorCode errorCode) {this.code = errorCode.getCode();this.message = errorCode.getMessage();}
}
service 层异常类:
@Data
@EqualsAndHashCode(callSuper = true)
public class ServiceException extends RuntimeException {/*** controller 层错误码* @see com.example.lotterysystem.common.errorcode.ServiceErrorCodeConstants*/private Integer code;/*** 错误描述信息*/private String message;/*** 无参构造方法,为了后续进行序列化*/public ServiceException() {}public ServiceException (Integer code, String message) {this.code = code;this.message = message;}public ServiceException (ErrorCode errorCode) {this.code = errorCode.getCode();this.message = errorCode.getMessage();}
}
CommonResult<T>
CommonResult<T> 作为 控制层 方法的 返回类型,封装接口调用结果,包括成功数据、错误数据 和 状态码,可以被 SpringBoot 框架自动转化为 JSON 或其他格式的响应体,发送给客户端
定义业务处理成功和失败时返回的 CommonResult<T>:
@Data
public class CommonResult<T> implements Serializable {/*** 错误码*/private Integer code;/*** 返回数据*/private T data;/*** 错误描述信息*/private String errorMessage;public CommonResult() {}/*** 运行成功时返回结果* @param data* @return* @param <T>*/public static <T> CommonResult<T> success(T data) {CommonResult<T> result = new CommonResult<>();result.code = GlobalErrorCodeConstants.SUCCESS.getCode();result.data = data;result.errorMessage = "";return result;}/*** 运行失败时返回结果* @param code* @param errorMessage* @return* @param <T>*/public static <T> CommonResult<T> fail(Integer code, String errorMessage) {Assert.isTrue(!GlobalErrorCodeConstants.SUCCESS.getCode().equals(code),"code = 200, 运行成功");CommonResult<T> result = new CommonResult<>();result.code = code;result.errorMessage = errorMessage;return result;}/*** 运行失败时返回结果* @param errorCode* @return* @param <T>*/public static <T> CommonResult<T> fail(ErrorCode errorCode) {return fail(errorCode.getCode(), errorCode.getMessage());}
}
其中,serializable 接口是 java 提供的一个标记接口(空接口),用于指示一个类的对象可以被序列化,无需实现任何方法,定义在 java.io 包中
此外,若想在 idea 中使用断言,需要先开启断言功能,可参考:
如何开启idea中的断言功能?_idea开启断言-CSDN博客
jackson
在前后端交互的过程中,经常会使用 JSON 格式来传递数据,这也就涉及到 序列化 和 反序列化,此外,我们在进行日志打印时,也会涉及到序列化
因此,我们可以定义一个工具类,来专门处理 序列化
在 java 中,通常使用 ObjectMapper 来处理 Java 对象与 JSON 数据之间的转换
查看 SpringBoot 框架中是如何实现的:
不同类型的对象序列化是基本相同的,都是使用 writeValueAsString 方法来进行序列化,因此我们主要来看反序列化:
可以看到,反序列化 Map 和 List 都调用了 tryParse 方法,并传递了两个参数:一个 lambda 表达式,一个 Exception
我们继续看 tryParse 方法:
其中,最主要的方法就是 parse.call(),通过 call() 方法,来执行定义的任务
且 tryParse 方法中对异常进行了处理:
check.isAssignableFrom(var4.getClass()) 判断抛出的异常是否是传入的 check 异常,若是,则抛出 JsonParseException 异常;若不是,则抛出 IllegalStateException 异常
可以看到,框架中通过 tryParse() 方法,巧妙地对异常进行了处理
因此,我们可以借鉴上述方法来进行实现
由于只需要使用一个 ObjectMapper 实例,因此可以创建 单例 ObjectMapper:
public class JacksonUtil {private JacksonUtil() {}private final static ObjectMapper OBJECT_MAPPER;static {OBJECT_MAPPER = new ObjectMapper();}private static ObjectMapper getObjectMapper() {return OBJECT_MAPPER;}
}
实现 tryParse 方法:
private static <T> T tryParse(Callable<T> parser, Class<? extends Exception> check) {try {return parser.call();} catch (Exception e) {if (check.isAssignableFrom(e.getClass())) {throw new JsonParseException(e);}throw new IllegalStateException(e);}}private static <T> T tryParse(Callable<T> parser) {return tryParse(parser, JsonParseException.class);}
实现序列化方法:
/*** 序列化* @param value* @return*/public static String writeValueAsString(Object value) {return tryParse(() -> getObjectMapper().writeValueAsString(value));}
反序列化:
/*** 反序列化* @param content* @param valueType* @return* @param <T>*/public static <T> T readValue(String content, Class<T> valueType) {return tryParse(() -> {return getObjectMapper().readValue(content, valueType);});}/*** 反序列化 List* @param content* @param param List 中元素类型* @return*/public static <T> T readListValue(String content, Class<?> param) {JavaType javaType = getObjectMapper().getTypeFactory().constructParametricType(List.class, param);return tryParse(() -> {return getObjectMapper().readValue(content, javaType);});}
在对 List 类型进行反序列化时,不能直接将 List 类型传递给 valueType,而是需要构造一个 JavaType 类型
完整代码:
public class JacksonUtil {private JacksonUtil() {}private final static ObjectMapper OBJECT_MAPPER;static {OBJECT_MAPPER = new ObjectMapper();}private static ObjectMapper getObjectMapper() {return OBJECT_MAPPER;}/*** 序列化* 对象 -> 字符串* @param value* @return*/public static String writeValueAsString(Object value) {return JacksonUtil.tryParse(() -> JacksonUtil.getObjectMapper().writeValueAsString(value));}/*** 反序列化* 字符串 -> 对象* @param content* @param valueType* @return* @param <T>*/public static <T> T readValue(String content, Class<T> valueType) {return JacksonUtil.tryParse(() -> {return JacksonUtil.getObjectMapper().readValue(content, valueType);});}/*** 反序列化 List* @param content* @param param* @return*/public static <T> T readListValue(String content, Class<?> param) {JavaType javaType = JacksonUtil.getObjectMapper().getTypeFactory().constructParametricType(List.class, param);return JacksonUtil.tryParse(() -> {return JacksonUtil.getObjectMapper().readValue(content, javaType);});}/*** 进行序列化/反序列化* @param parser* @param check* @return* @param <T>*/private static <T> T tryParse(Callable<T> parser, Class<? extends Exception> check) {try {return parser.call();} catch (Exception e) {if (check.isAssignableFrom(e.getClass())) {throw new JsonParseException(e);}throw new IllegalStateException(e);}}private static <T> T tryParse(Callable<T> parser) {return tryParse(parser, JsonParseException.class);}
}
加密工具
在对敏感信息(如密码、手机号等)进行存储时,需要进行加密,从而保证数据的安全性,若直接明文存储,当黑客入侵数据库时,就可以轻松拿到用户的相关信息,从而造成信息泄露或财产损失
在这里,可以使用 md5 对用户密码进行加密:
采用 判断哈希值是否一致 的方法来判断密码是否正确
除了密码以外,手机号也是重要的隐私数据,但手机号与密码不同:对于后端来说,不知道密码的明文也不会对业务逻辑造成影响;但后端需要明文的手机号,在一些情况下给用户发送短信
因此,对于手机号这样的信息,需要采用相对安全的做法:先对手机号进行对称加密,再将加密结果存储在数据库中;在使用时再使用密钥对其进行解密
在这里,我们使用 Hutool 的加密工具:加密解密工具-SecureUtil
引入依赖:
<dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.8.16</version>
</dependency>
上述引入的是所有模块,但其实绝大部分项目可能都用不上,因此可以只引入需要的模块
使用对应方法进行加解密:
@SpringBootTest
public class SecurityTest {/*** 使用 aes 对用户手机号进行加密*/@Testvoid test() {// 密钥String key = "1234567890abcdefghijklmn";AES aes = SecureUtil.aes(key.getBytes());String encrypt = aes.encryptHex("12345678").toString();System.out.println("加密结果:" +encrypt);String decrypt = aes.decryptStr(encrypt);System.out.println("解密结果:" + decrypt);}/*** 使用 md5 对用户密码进行加密*/@Testvoid md5Test() {// 生成随机盐值String salt = UUID.randomUUID().toString().replace("-", "");System.out.println("salt: " + salt);String password = "123456";// 进行加密String encrypt = DigestUtil.md5Hex(password +salt);System.out.println("加密结果:" + encrypt);}
}
测试结果:
实现 SecurityUtil 工具类:
@Slf4j
public class SecurityUtil {// 密钥private static final String AES_KEY = "3416b730f0f244128200c59fd07e6249";/*** 使用 md5 对密码进行加密* @param password 输入的密码* @return 密码 + 盐值*/public static String encipherPassword(String password) {String salt = UUID.randomUUID().toString().replace("-", "");String secretPassword = DigestUtil.md5Hex(password + salt);return secretPassword + salt;}/*** 验证用户输入的密码是否正确* @param inputPassword 用户输入密码* @param sqlPassword 数据库中存储密码* @return*/public static Boolean verifyPassword(String inputPassword, String sqlPassword) {log.info("用户输入密码:{}, 数据库获取密码:{}", inputPassword, sqlPassword);if (!StringUtils.hasLength(inputPassword)) {return false;}if (!StringUtils.hasLength(sqlPassword) || sqlPassword.length() != 64) {return false;}String salt = sqlPassword.substring(32, 64);String secretPassword = DigestUtil.md5Hex(inputPassword + salt);return sqlPassword.substring(0, 32).equals(secretPassword);}/*** 使用 aes 对用户手机号进行加密* @param phone* @return*/public static String encipherPhone(String phone) {return SecureUtil.aes(AES_KEY.getBytes()).encryptHex(phone);}/*** 对加密的手机号进行解密* @param encryptPhone* @return*/public static String decryptPhone(String encryptPhone) {// log.info("解析 encryptPhone:{}", encryptPhone);return SecureUtil.aes(AES_KEY.getBytes()).decryptStr(encryptPhone);}
}
AES 使用的密钥可以使用 UUID 生成
日志配置
在 logback-spring.xml 中进行配置:
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="60 seconds" debug="false"><springProfile name="dev"><!--输出到控制台--><appender name="console" class="ch.qos.logback.core.ConsoleAppender"><encoder><pattern>%d{HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n%ex</pattern></encoder></appender><root level="info"><appender-ref ref="console" /></root></springProfile><springProfile name="prod,test"><!--ERROR级别的日志放在logErrorDir目录下,INFO级别的日志放在logInfoDir目录下--><property name="logback.logErrorDir" value="/root/lottery-system/logs/error"/><property name="logback.logInfoDir" value="/root/lottery-system/logs/info"/><property name="logback.appName" value="lotterySystem"/><contextName>${logback.appName}</contextName><!--ERROR级别的日志配置如下--><appender name="fileErrorLog" class="ch.qos.logback.core.rolling.RollingFileAppender"><!--日志名称,如果没有File 属性,那么只会使用FileNamePattern的文件路径规则如果同时有<File>和<FileNamePattern>,那么当天日志是<File>,明天会自动把今天的日志改名为今天的日期。即,<File> 的日志都是当天的。--><File>${logback.logErrorDir}/error.log</File><!-- 日志level过滤器,保证error.***.log中只记录ERROR级别的日志--><filter class="ch.qos.logback.classic.filter.LevelFilter"><level>ERROR</level><onMatch>ACCEPT</onMatch><onMismatch>DENY</onMismatch></filter><!--滚动策略,按照时间滚动 TimeBasedRollingPolicy--><rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"><!--文件路径,定义了日志的切分方式——把每一天的日志归档到一个文件中,以防止日志填满整个磁盘空间--><FileNamePattern>${logback.logErrorDir}/error.%d{yyyy-MM-dd}.log</FileNamePattern><!--只保留最近14天的日志--><maxHistory>14</maxHistory><!--用来指定日志文件的上限大小,那么到了这个值,就会删除旧的日志--><!--<totalSizeCap>1GB</totalSizeCap>--></rollingPolicy><!--日志输出编码格式化--><encoder><charset>UTF-8</charset><pattern>%d [%thread] %-5level %logger{36} %line - %msg%n%ex</pattern></encoder></appender><!--INFO级别的日志配置如下--><appender name="fileInfoLog" class="ch.qos.logback.core.rolling.RollingFileAppender"><!--日志名称,如果没有File 属性,那么只会使用FileNamePattern的文件路径规则如果同时有<File>和<FileNamePattern>,那么当天日志是<File>,明天会自动把今天的日志改名为今天的日期。即,<File> 的日志都是当天的。--><File>${logback.logInfoDir}/info.log</File><!--自定义过滤器,保证info.***.log中只打印INFO级别的日志, 填写全限定路径--><filter class="com.example.lotterysystem.common.filter.InfoLevelFilter"/><!--滚动策略,按照时间滚动 TimeBasedRollingPolicy--><rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"><!--文件路径,定义了日志的切分方式——把每一天的日志归档到一个文件中,以防止日志填满整个磁盘空间--><FileNamePattern>${logback.logInfoDir}/info.%d{yyyy-MM-dd}.log</FileNamePattern><!--只保留最近14天的日志--><maxHistory>14</maxHistory><!--用来指定日志文件的上限大小,那么到了这个值,就会删除旧的日志--><!--<totalSizeCap>1GB</totalSizeCap>--></rollingPolicy><!--日志输出编码格式化--><encoder><charset>UTF-8</charset><pattern>%d [%thread] %-5level %logger{36} %line - %msg%n%ex</pattern></encoder></appender><root level="info"><appender-ref ref="fileErrorLog" /><appender-ref ref="fileInfoLog"/></root></springProfile>
</configuration>
若当前处于开发环境,则将日志直接输出到控制台
若当前位于生产及测试环境,则分开存放 error 级别和 info 级别的日志,并采用基于时间的日志滚动策略,设置日志保留周期为14天
由于需要过滤 info 级别的日志,因此我们需要新增一个自定义过滤器:
在 com.example.lotterysystem.common.filter 路径下新增 InfoLevelFilter:
public class InfoLevelFilter extends Filter<ILoggingEvent> {@Overridepublic FilterReply decide(ILoggingEvent iLoggingEvent) {if (iLoggingEvent.getLevel().toInt() == Level.INFO.toInt()){return FilterReply.ACCEPT;}return FilterReply.DENY;}
}
在 application.yml 中新增配置:
logging:config: classpath:logback-spring.xml