封面来源:由博主个人绘制,如需使用请联系博主。
0. 前言
不知不觉大学都要毕业了,然而自己还是个菜狗 🐶,写个毕业设计前端不咋会就算了,后端也写得菜 🥬。回望四年,这毕业设计还是我真正写完的一个项目(以前的项目要么半途而废,要么仅仅是一个 demo)。
曾子曰:“吾日三省吾身”,完成了毕业设计后进行一个总结还是很有必要的。
在本文中,我将从前端与后端进行总结,虽说是总结,也仅仅是记录一下自己曾经的知识盲点罢了。
后端技术栈:SpringBoot + SpringSecurity + JWT + MyBatis-Plus + MapStruct + Hutool(默认引入 Lombok)
后端数据库:Redis + MySQL 8.0
前端技术栈:Vue.js + Element-UI
关于后端 JWT 和 SpringSecurity 的相关使用,可以参看【JWT的基本使用】一文。
关于后端 MapStruct 的使用,可以参看【MapStruct使用手册】一文。
关于后端 MyBatis-Plus 的使用,可以参看【MyBatis的“好基友”——MyBatisPlus】一文。
1. 后端 - 自定义类
1.1 JSR-303 异常信息类
JSR-303 异常信息类,在使用 SpringBoot 进行全局异常处理时,针对产生的 MethodArgumentNotValidException
异常进行错误信息组装。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Getter @Setter public class JSR303ExceptionMessage { private String field; private String objectName; private Object rejectedValue; private String errorMsg; }
1.2 自定义业务异常类
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 public class BusinessException extends RuntimeException { private static final long serialVersionUID = 5382880814065968592L ; private String code = "BOOT-001" ; private Set<String> errors = new HashSet <>(); public BusinessException () { } public BusinessException (String code) { this .code = code; } public BusinessException (String code, Throwable cause) { super (cause); this .code = code; this .errors.add(cause.getMessage()); } public BusinessException addError (String msg) { this .errors.add(msg); return this ; } @Override public String getMessage () { String msg; if (this .errors.isEmpty()) { msg = super .getMessage(); } else { StringBuilder sb = new StringBuilder (this .errors.size() * 15 ); for (String message : this .errors) { sb.append(message).append("," ); } sb.deleteCharAt(sb.length() - 1 ); msg = sb.toString(); } return msg == null ? this .code() : msg; } public String code () { return this .code; } public String message () { return this .getMessage(); } }
1.3 统一返回值
后端 Service 层都将统一返回一个类型,便于后续操作。
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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 public class Result <T> implements Serializable { private static final long serialVersionUID = 5244827382777974660L ; public static final String SUCCESS_CODE = "BOOT-000" ; private String code; private String msg; private T data; private boolean log = false ; public static <T> Result<T> success () { return new Result <>(); } public static <T> Result<T> success (T data) { return new Result <>(data); } public static <T> Result<T> error (String code, String msg) { return new Result <>(code, msg); } public Result (String code, String msg) { this .code = code; this .msg = msg; this .data = null ; } public Result (T data) { this .code = SUCCESS_CODE; this .msg = ResultMsg.SUCCESS; this .data = data; } public Result () { this .code = SUCCESS_CODE; this .msg = ResultMsg.SUCCESS; this .data = null ; } public String getCode () { return code; } public Result<T> setCode (String code) { this .code = code; return this ; } public String getMsg () { return msg; } public Result<T> setMsg (String msg) { this .msg = msg; return this ; } public T getData () { return data; } public void setData (T data) { this .data = data; } public boolean isLog () { return this .log; } public Result<T> setLog (boolean log) { this .log = log; return this ; } }
1.4 分页请求对象
为方便在查询时进行分页操作,规定将前端传递的分页请求进行包装。
注意: 后端使用的数据持久层框架是 Mybatis-Plus。
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 @Getter @Setter @AllArgsConstructor @NoArgsConstructor public class PageRequest <T> { private Integer currentPage; private Integer pageSize; private Map<String, Object> data; public static <T> Page<T> getPageInfo (PageRequest<T> pageRequest) { return new Page <>(pageRequest.getCurrentPage(), pageRequest.getPageSize()); } }
1.5 枚举接口
后端中的大多数枚举都需要实现这个接口并重写其中的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public interface EnumInfo extends Serializable { String code () ; String message () ; }
1.6 实体基类
后端中使用到的 所有 实体都需要继承实体基类。
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 60 61 62 63 64 65 66 67 public abstract class BaseEntity implements Serializable { private static final long serialVersionUID = 6581451145034114785L ; @TableId(type = IdType.ASSIGN_ID) private Long id; @TableField(fill = FieldFill.INSERT) private Date createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private Date updateTime; @TableField(fill = FieldFill.INSERT, value = "is_deleted") @TableLogic private Integer deleted; public Integer getDeleted () { return deleted; } public void setDeleted (Integer deleted) { this .deleted = deleted; } public Long getId () { return id; } public void setId (Long id) { this .id = id; } public Date getCreateTime () { return createTime; } @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") public void setCreateTime (Date createTime) { this .createTime = createTime; } public Date getUpdateTime () { return updateTime; } @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") public void setUpdateTime (Date updateTime) { this .updateTime = updateTime; } }
1.7 数据传输对象基类
后端使用到的所有 DTO(数据传输对象)都需要继承数据传输对象基类。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public abstract class BaseDto implements Serializable { private static final long serialVersionUID = -6349988895227283284L ; @JsonSerialize(using = ToStringSerializer.class) private Long id; public BaseDto () { } public Long getId () { return id; } public void setId (Long id) { this .id = id; } }
2. 后端 - SpringBoot
2.1 统一异常拦截
为了更好地处理后端代码中的异常信息,可以使用 SpringBoot 提供的统一异常拦截。
步骤也很简单:
1、创建一个全局异常拦截器类,假设叫:GlobalExceptionInterceptor
;
2、在这个类上使用注解:@ControllerAdvice
;
3、在这个类中编写处理各个异常的方法(通常处理一个异常编写一个方法),并在方法上使用注解: @ExceptionHandler
,这个注解的 value 属性值为被处理的异常类。
比如我所使用的全局异常拦截器类是这样的:
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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 @Slf4j @ControllerAdvice @ResponseBody public class GlobalExceptionInterceptor { @ExceptionHandler(value = MethodArgumentNotValidException.class) public Result<JSR303ExceptionMessage> jsr303ExceptionHandler (MethodArgumentNotValidException e) { JSR303ExceptionMessage jsr303ExceptionMessage = new JSR303ExceptionMessage (); FieldError fieldError = Objects.requireNonNull(e.getBindingResult().getFieldError()); jsr303ExceptionMessage.setErrorMsg(fieldError.getDefaultMessage()); jsr303ExceptionMessage.setField(fieldError.getField()); jsr303ExceptionMessage.setObjectName(fieldError.getObjectName()); jsr303ExceptionMessage.setRejectedValue(fieldError.getRejectedValue()); Result<JSR303ExceptionMessage> result = new Result <>(); result.setCode(ErrorCode.JSR303_VERIFICATION_FAILED).setMsg("提交的数据错误" ).setData(jsr303ExceptionMessage); return result; } @ExceptionHandler(value = BusinessException.class) public Result<BusinessException> businessExceptionHandler (BusinessException e) { return Result.error(e.code(), e.message()); } @ExceptionHandler(value = ExcelAnalysisException.class) public Result<ExcelAnalysisException> excelAnalysisExceptionHandler (ExcelAnalysisException e) { return Result.error(ErrorCode.EXCEL_ANALYSIS_ERROR, e.getMessage()); } @ExceptionHandler(value = AccessDeniedException.class) public void accessDeniedExceptionHandler (AccessDeniedException e) throws AccessDeniedException{ throw new AccessDeniedException ("没有访问权限" ); } @ExceptionHandler(value = MaxUploadSizeExceededException.class) public Result<MaxUploadSizeExceededException> maxUploadSizeExceededException (MaxUploadSizeExceededException e) { return Result.error(ErrorCode.MAX_UPLOAD_SIZE_EXCEEDED, "上传文件尺寸过大" ); } @ExceptionHandler(value = OSSException.class) public Result<OSSException> ossException (OSSException e) { return Result.error(ErrorCode.UPLOAD_FILE_TO_OSS_ERROR, "上传至客户端失败" ); } @ExceptionHandler(value = ClientException.class) public Result<ClientException> clientException (ClientException e) { return Result.error(ErrorCode.ALI_OSS_CLIENT_ERROR, "文件客户端异常,请联系系统管理员处理!" ); } @ExceptionHandler(value = Exception.class) public Result<Exception> exceptionHandler (Exception e) { log.error(e.getMessage()); return Result.error(ErrorCode.UNKNOWN_ERROR, "未知的错误,请联系系统管理员处理!" ); } }
2.2 JSR-303 校验
前端传递数据作为后端方法的入参时,我们需要对这些参数进行校验,比方说检验是否非空。为了使代码简洁而不采取 if - else
的方式进行判断,而是采用 JSR-303 检验。
步骤也很简单:
1、为 Controller 里方法的参数使用 @Valid
注解;
2、在参数对应的类的属性上使用 JSR-303 注解。
比如:
1 2 3 4 5 6 7 8 @PostMapping("submit") public Result<Long> saveOrUpdate (@Valid @RequestBody DeptDefinitionDto dto) { if (dto.getId() == null ) { return deptService.createDept(dto); } else { return deptService.updateDeptInfo(dto); } }
DeptDefinitionDto
对应的部分代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Getter @Setter public class DeptDefinitionDto { @NotNull(message = "部门名称不能为空") private String deptName; @FaxNum(notNull = false) private String fax; }
2.3 自定义校验注解
在特定的场景下,使用 JSR-303 提供的注解无法完成我们的校验需求,这个时候就需要自定义注解,比方说前文中 DeptDefinitionDto
类中的 @FaxNum
注解。
自定义注解的步骤也很简单:
1、创建自定义注解,注解中要有属性 message
、groups
和 payload
,还要在自定义的注解上使用注解 @Constraint
,并指定其 validatedBy
属性值为自定义的约束验证器(第二步创建)。
2、创建自定义的约束验证器,这个类实现 ConstraintValidator
接口,这个接口的泛型为:自定义的注解和需要被校验的参数类型。
比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @Documented @Constraint(validatedBy = FaxNumValidator.class) @Target({ElementType.PARAMETER, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) public @interface FaxNum { String message () default "传真电话号码格式错误" ; Class[] groups() default {}; Class[] payload() default {}; boolean notNull () default true ; }
自定义约束验证器 FaxNumValidator
:
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 public class FaxNumValidator implements ConstraintValidator <FaxNum, String> { private static final String FAX_NUM_REG_EXP = "\\d{3}-\\d{8}|\\d{4}-\\d{7}" ; private static boolean notNull = true ; @Override public void initialize (FaxNum constraintAnnotation) { notNull = constraintAnnotation.notNull(); } @Override public boolean isValid (String s, ConstraintValidatorContext constraintValidatorContext) { if (s == null || "" .equals(s)) { return !notNull; } return s.matches(FAX_NUM_REG_EXP); } }
在上述代码中使用了正则表达式进行内容验证,常用的正则表达式参考:正则表达式在线测试
2.4 文件上传限制
在上传文件时,Spring 默认单个文件最大为 1M,单次请求中文件大小合计最大为 10M,如果查过这个数值就会抛出异常。
当然这些数值也是可以修改的,只需要在配置文件追加:
1 2 3 4 5 6 7 8 9 10 11 spring: servlet: multipart: max-file-size: 20MB max-request-size: 200MB server: tomcat: max-swallow-size: -1
并在全局异常拦截中追加:
1 2 3 4 5 6 7 8 9 10 @ExceptionHandler(value = MaxUploadSizeExceededException.class) public Result<MaxUploadSizeExceededException> maxUploadSizeExceededException (MaxUploadSizeExceededException e) { return Result.error(ErrorCode.MAX_UPLOAD_SIZE_EXCEEDED, "上传文件尺寸过大" ); }
3. 后端 - 阿里云 OSS
在本次的项目中使用到了阿里云 OSS,用于存储需要上传的文件。在此介绍一下后端整合阿里云 OSS 的方法,默认已注册阿里云,并成功配置 OSS(获取到公钥和私钥)。
首先需要导入依赖:
1 2 3 4 5 <dependency > <groupId > com.aliyun.oss</groupId > <artifactId > aliyun-sdk-oss</artifactId > <version > 3.10.2</version > </dependency >
然后在配置文件中追加以下配置:
1 2 3 4 5 6 7 oss: endpoint: oss-cn-chengdu.aliyuncs.com accessKeyId: xxxx accessKeySecret: xxxx bucketName: gsams-files dirName: exam-outline/
注意: 导入的依赖并不是 SpringBoot 的 starter,仅仅是 OSS 的 SDK,这里编写的配置文件仅仅是为了方便管理,如果不写在配置文件中,也可以硬编码在 Java 代码中。
读取追加的 OSS 配置信息:
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 60 61 62 63 64 @Getter @Setter @Configuration public class OssConstant implements InitializingBean { public static long FILE_MAX_SIZE = 1024 * 1024 * 10L ; public static long FILE_EXPIRATION_TIME = 1000L * 3600L * 24L * 365L * 100L ; public static String END_POINT; public static String ACCESS_KEY_ID; public static String ACCESS_KEY_SECRET; public static String BUCKET_NAME; public static String DIR_NAME; @Value("${oss.endpoint}") private String endpoint; @Value("${oss.accessKeyId}") private String accessKeyId; @Value("${oss.accessKeySecret}") private String accessKeySecret; @Value("${oss.bucketName}") private String bucketName; @Value("${oss.dirName}") private String dirName; @Override public void afterPropertiesSet () { END_POINT = endpoint; ACCESS_KEY_ID = accessKeyId; ACCESS_KEY_SECRET = accessKeySecret; BUCKET_NAME = bucketName; DIR_NAME = dirName; } }
编写 OSS 的配置类,将读取到的配置信息注入到 Spring 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Configuration public class OssConfig { @Bean public OSSClient ossClient () { return new OSSClient ( OssConstant.END_POINT, new DefaultCredentialProvider (OssConstant.ACCESS_KEY_ID, OssConstant.ACCESS_KEY_SECRET), new ClientConfiguration ()); } }
最后编写具体 Service 服务(仅以文件上传为例):
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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 @Slf4j @Service public class OssService { @Autowired private OSSClient ossClient; public Result<String> uploadPdf (MultipartFile file) { if (FileUtil.isNotPdfFile(file)) { log.error("pdf文件上传失败" ); throw new BusinessException (ErrorCode.FILE_TYPE_ERROR).addError("上传文件类型错误" ); } if (file.getSize() <= 0 || file.getSize() > OssConstant.FILE_MAX_SIZE) { throw new BusinessException (ErrorCode.FILE_SIZE_TOO_LARGE).addError("尺寸过大" ); } String oldName = file.getOriginalFilename(); Assert.notNull(oldName, "文件全名称不能为空" ); String suffix = oldName.substring(oldName.lastIndexOf("." ) + 1 ); String prefix = oldName.substring(0 , oldName.lastIndexOf("." )); String fileName = OssConstant.DIR_NAME + IdUtil.simpleUUID() .replace("-" , "" ) + "_" + oldName; String fileType = contentType(suffix); try (InputStream inputStream = file.getInputStream()){ return Result.success(putFile(inputStream, fileType, fileName)); } catch (IOException e) { throw new BusinessException (ErrorCode.FILE_IO_ERROR).addError("PDF上传失败" ); } } private String putFile (InputStream input, String fileType, String fileName) { ObjectMetadata meta = new ObjectMetadata (); meta.setContentType(fileType); meta.setCacheControl("no-cache" ); PutObjectRequest request = new PutObjectRequest (OssConstant.BUCKET_NAME, fileName, input, meta); ossClient.putObject(request); return getOssUrl(fileName); } private String getOssUrl (String fileName) { return "https://" + OssConstant.BUCKET_NAME + "." + OssConstant.END_POINT + "/" + fileName; } private String getFileName (String fileUrl) { String str = "aliyuncs.com/" ; int beginIndex = fileUrl.indexOf(str); if (beginIndex == -1 ) { return null ; } return fileUrl.substring(beginIndex + str.length()); } private List<String> getFileName (List<String> fileUrls) { List<String> names = new ArrayList <>(); for (String url : fileUrls) { names.add(getFileName(url)); } return names; } private String contentType (String fileType) { fileType = fileType.toLowerCase(); String contentType; switch (fileType) { case "bmp" : contentType = "image/bmp" ; break ; case "gif" : contentType = "image/gif" ; break ; case "png" : case "jpeg" : case "jpg" : contentType = "image/jpeg" ; break ; case "html" : contentType = "text/html" ; break ; case "txt" : contentType = "text/plain" ; break ; case "vsd" : contentType = "application/vnd.visio" ; break ; case "pdf" : contentType = "application/pdf" ; break ; case "ppt" : case "pptx" : contentType = "application/vnd.ms-powerpoint" ; break ; case "doc" : case "docx" : contentType = "application/msword" ; break ; case "xml" : contentType = "text/xml" ; break ; case "mp4" : contentType = "video/mp4" ; break ; default : contentType = "application/octet-stream" ; break ; } return contentType; } }
如果需要使用,接收前端传递的 MultipartFile
即可。比如,在上传 PDF:
1 2 3 4 @PostMapping("upload/pdf") public Result<String> uploadPdf (@RequestParam("file") MultipartFile file) { return ossService.uploadPdf(file); }
4.后端 - logback
在项目中,有时需要打印日志信息并保存到日志文件中,我们可以使用 logback 来实现这个功能。
为了在不同环境下打印不同的日志信息,我们需要使用多环境配置文件。
添加配置文件:application-dev.yml
,这个配置文件在开发环境下使用,将现阶段所有配置信息都移到这个配置文件中。修改 application.yml
为:
1 2 3 4 spring: profiles: active: dev
然后在项目的 resources 目录下创建 logback-spring.xml 文件(名称不能变,必须是这个,且仅在 Spring 环境下生效),并添加以下内容:
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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 <?xml version="1.0" encoding="UTF-8" ?> <configuration scan ="true" scanPeriod ="10 seconds" > <contextName > logback</contextName > <property name ="log.path" value ="../logs" /> <property name ="CONSOLE_LOG_PATTERN" value ="%yellow(%date{yyyy-MM-dd HH:mm:ss}) |%highlight(%-5level) |%blue(%thread) |%blue(%file:%line) |%green(%logger) |%cyan(%msg%n)" /> <appender name ="CONSOLE" class ="ch.qos.logback.core.ConsoleAppender" > <filter class ="ch.qos.logback.classic.filter.ThresholdFilter" > <level > INFO</level > </filter > <encoder > <Pattern > ${CONSOLE_LOG_PATTERN}</Pattern > <charset > UTF-8</charset > </encoder > </appender > <appender name ="INFO_FILE" class ="ch.qos.logback.core.rolling.RollingFileAppender" > <file > ${log.path}/log_info.log</file > <encoder > <pattern > %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern > <charset > UTF-8</charset > </encoder > <rollingPolicy class ="ch.qos.logback.core.rolling.TimeBasedRollingPolicy" > <fileNamePattern > ${log.path}/info/log-info-%d{yyyy-MM-dd}.%i.log</fileNamePattern > <timeBasedFileNamingAndTriggeringPolicy class ="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP" > <maxFileSize > 100MB</maxFileSize > </timeBasedFileNamingAndTriggeringPolicy > <maxHistory > 15</maxHistory > </rollingPolicy > <filter class ="ch.qos.logback.classic.filter.LevelFilter" > <level > INFO</level > <onMatch > ACCEPT</onMatch > <onMismatch > DENY</onMismatch > </filter > </appender > <appender name ="WARN_FILE" class ="ch.qos.logback.core.rolling.RollingFileAppender" > <file > ${log.path}/log_warn.log</file > <encoder > <pattern > %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern > <charset > UTF-8</charset > </encoder > <rollingPolicy class ="ch.qos.logback.core.rolling.TimeBasedRollingPolicy" > <fileNamePattern > ${log.path}/warn/log-warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern > <timeBasedFileNamingAndTriggeringPolicy class ="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP" > <maxFileSize > 100MB</maxFileSize > </timeBasedFileNamingAndTriggeringPolicy > <maxHistory > 15</maxHistory > </rollingPolicy > <filter class ="ch.qos.logback.classic.filter.LevelFilter" > <level > warn</level > <onMatch > ACCEPT</onMatch > <onMismatch > DENY</onMismatch > </filter > </appender > <appender name ="ERROR_FILE" class ="ch.qos.logback.core.rolling.RollingFileAppender" > <file > ${log.path}/log_error.log</file > <encoder > <pattern > %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern > <charset > UTF-8</charset > </encoder > <rollingPolicy class ="ch.qos.logback.core.rolling.TimeBasedRollingPolicy" > <fileNamePattern > ${log.path}/error/log-error-%d{yyyy-MM-dd}.%i.log</fileNamePattern > <timeBasedFileNamingAndTriggeringPolicy class ="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP" > <maxFileSize > 100MB</maxFileSize > </timeBasedFileNamingAndTriggeringPolicy > <maxHistory > 15</maxHistory > </rollingPolicy > <filter class ="ch.qos.logback.classic.filter.LevelFilter" > <level > ERROR</level > <onMatch > ACCEPT</onMatch > <onMismatch > DENY</onMismatch > </filter > </appender > <springProfile name ="dev" > <logger name ="indi.mofan" level ="INFO" /> <root level ="INFO" > <appender-ref ref ="CONSOLE" /> <appender-ref ref ="INFO_FILE" /> <appender-ref ref ="WARN_FILE" /> <appender-ref ref ="ERROR_FILE" /> </root > </springProfile > </configuration >
如果需要使用上述日志配置,需要修改的地方有:
1、日志的存放地址:
1 <property name ="log.path" value ="../logs" />
2、指定自己项目的包路径:
1 <logger name ="indi.mofan" level ="INFO" />
完成以上步骤后,我们就可以愉快地使用 Lombok 提供的 @Slf4j
注解输出日志了。 🎉
5. 后端 - 切面
日志除了输出到日志文件中之外,也可以保存到数据库。
针对将日志保存到数据库这个需求,可以使用 切面 来解决。
导入依赖:
1 2 3 4 5 6 <dependency > <groupId > org.aspectj</groupId > <artifactId > aspectjweaver</artifactId > <version > 1.9.6</version > </dependency >
编写日志切面服务:
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 @Aspect @Slf4j @Component public class LogAspect { @Autowired private LogService logService; @Autowired private CurrentUser currentUser; @Pointcut("execution(public * indi.mofan.controller..*.*(..))") public void controllerMethod () { } @SuppressWarnings("unchecked") @AfterReturning(returning = "object", pointcut = "controllerMethod()") public void logResultInfo (Object object) { Result<Object> result = (Result<Object>) object; if (result.isLog() && Result.SUCCESS_CODE.equals(result.getCode())) { UserInfo currentUser = this .currentUser.getCurrentUser(); LogInfoDefinitionDto dto = new LogInfoDefinitionDto (); dto.setUserAccount(currentUser.getUserAccount()); dto.setUserType(currentUser.getUserType().code()); dto.setAction(result.getMsg()); logService.createOneLog(dto); log.info("用户类型:{} - 执行账号:{} - 所做操作:{}" , dto.getUserType(), dto.getUserAccount(), dto.getAction()); } } }
通过上述代码可以看到,在方法执行后才执行配置的切面,由于统一了返回值(参看 【1.3 统一返回值】),配置起来也变得简单。
6. 后端 - 配置文件加密
在后端的配置文件中往往有很多敏感信息,比如各种数据库密码、各种密钥等,如果将这些信息进行明文显示,就有可能让不法分子有可乘之机。
因此,最好能对这些信息进行加密。
导入依赖:
1 2 3 4 5 <dependency > <groupId > com.github.ulisesbocchio</groupId > <artifactId > jasypt-spring-boot-starter</artifactId > <version > 3.0.3</version > </dependency >
配置文件中增加配置:
1 2 3 4 jasypt: encryptor: password: 12345
然后编写一个测试用例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @SpringBootTest public class JasyptEncryptorTest { @Autowired private StringEncryptor encryptor; @Test public void testEncrypt () { final String originPwd = "123456" ; String encryptedPwd = encryptor.encrypt(originPwd); Assertions.assertEquals(originPwd, encryptor.decrypt(encryptedPwd)); System.out.println("未加密数据:" + originPwd); System.out.println("加密后的数据:" + encryptedPwd); } }
运行后,测试通过,控制台打印出:
未加密数据:123456
加密后的数据:ecqo2ZfOXI3OMMYqNHTePr/uyZHhCyKNFUU4xXjXkoUPpcNDeuZAdX7jhYIQQYWd
上述代码是什么意思呢?
就是使用密钥 12345(privateKey)对原始密码 123456(originPwd)进行加密,最终打印出加密前和加密后的数据,因此密钥是绝不能暴露的,在实际使用时,不能直接将明文密钥填写在配置文件中 。
那么问题又来了,怎么使用加密后的数据呢?
只需要将配置文件中需要进行加密的数据替换后即可,但是需要加上 加密前后缀 。
比如:
1 2 3 datasource: username: root password: ENC(ecqo2ZfOXI3OMMYqNHTePr/uyZHhCyKNFUU4xXjXkoUPpcNDeuZAdX7jhYIQQYWd)
替换并移除配置文件中的明文密钥后肯定是无法启动项目的,因为并没有告知程序所使用的密钥。那怎么搞?
方式有三:
1、将密钥直接作为程序启动时的 命令行参数 :
java -jar yourprojectname.jar --jasypt.encryptor.password=12345
2、将密钥作为程序启动时的 应用环境变量 :
java -Djasypt.encryptor.password=12345 -jar yourprojectname.jar
3、将密钥配置为 系统的环境变量 :
比如我们提前设置系统环境变量:JASYPT_ENCRYPTOR_PASSWORD = 12345,那么在 SpringBoot 的配置文件中添加以下配置即可:
1 2 3 jasypt: encryptor: password: ${JASYPT_ENCRYPTOR_PASSWORD:}
如果使用 IDEA 作为后端开发工具,只需要在虚拟机启动参数追加以下内容即可:
-Djasypt.encryptor.password=12345
如:
6.1 自定义加密前后缀
jasypt 默认的加密前后缀是 ENC()
,那么有没有什么办法可以自定义呢?比如改成 MOFAN()
。
只需要在配置文件追加以下内容即可:
1 2 3 4 5 jasypt: encryptor: property: prefix: MOFAN( suffix: )
6.2 更多自定义
默认情况下 jasypt 采用的加密算法为 PBEWITHHMACSHA512ANDAES_256
,这些默认信息都在 JasyptEncryptorConfigurationProperties
类中,我们前往这个类查看:
因此,如果我们需要自定义更多的内容,只需要在配置文件中进行修改即可。
7. 后端 - 工具类
在说工具类之前,就不得不介绍一个非常好用的 Java 工具包类库 —— Hutool,这个类库是由国人开发的,因此参考文档和 API 文档都是中文的,十分友好。
你就在此地不要走动,我把参考文档和 API 文档的链接都放在这里。
使用 Hutool 也很简单,只需要导入依赖就可以了:
1 2 3 4 5 <dependency > <groupId > cn.hutool</groupId > <artifactId > hutool-all</artifactId > <version > Latest Version</version > </dependency >
Hutool 是多模块开发的,当然除了上述这种方式外,也可以按需导入自己需要的模块。
至于 Hutool 的使用方式就查阅参考文档和 API 文档吧,十分详细。
7.2 使用的工具类
ObjectUtil
ObjectUtil:对象判空、Map 非空过滤、Map key 值转换、Map value 整合。
不要吐槽这个命名,我自己都想吐槽,或许叫 MapUtil 或者 CollectionUtil 会好点?🤔
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 60 public class ObjectUtil { public static boolean checkValue (Object object) { if (object instanceof String && "" .equals((((String) object).trim()))) { return false ; } return null != object; } public static Map<String, Object> filterEmptyStringValue (Map<String, Object> data) { return data.entrySet().stream() .filter(entry -> checkValue(entry.getValue())) .collect(Collectors.toMap( (e) -> (String) e.getKey(), Map.Entry::getValue )); } public static Map<String, Object> toUnderLineCaseKey (Map<String, Object> data) { return data.entrySet().stream() .collect(Collectors.toMap((e) -> StrUtil.toUnderlineCase(e.getKey()), Map.Entry::getValue)); } public static Map<String, Object> toUnderLineCaseKeyAndFilterValue (Map<String, Object> data) { return toUnderLineCaseKey(filterEmptyStringValue(data)); } public static <K, V> List<V> getMapValueList (Map<K, V> map) { Collection<V> values = map.values(); return new ArrayList <>(values); } }
NumberUtil
NumberUtil:double 转 int。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public class NumberUtil { public static int doubleToInt (double d) { return Integer.parseInt(new DecimalFormat ("0" ).format(d)); } }
FileUtil
FileUtil:判断 MultipartFile 是否为 Excel、判断 MultipartFile 是否为 PDF。
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 60 61 62 63 64 65 66 67 68 69 @Slf4j public class FileUtil { private static final String FILE_ZIP = "zip" ; private static final String FILE_XLS = ".xls" ; private static final String FILE_XLSX = ".xlsx" ; private static final String FILE_PDF = ".pdf" ; public static boolean isNotExcelFile (MultipartFile file) { if (file == null ) { return true ; } String filename = file.getOriginalFilename(); String fileType = null ; if (filename != null ) { fileType = filename.substring(filename.lastIndexOf("." )); } if (FILE_XLS.equalsIgnoreCase(fileType) || FILE_XLSX.equalsIgnoreCase(fileType)) { String type; try { type = FileTypeUtil.getType(file.getInputStream()); if (FILE_ZIP.contains(type) || FILE_XLS.contains(type)) { return false ; } } catch (Exception e) { return true ; } } return true ; } public static boolean isNotPdfFile (MultipartFile file) { if (file == null ) { return true ; } String filename = file.getOriginalFilename(); String fileType = null ; if (filename != null ) { fileType = filename.substring(filename.lastIndexOf("." )); } if (FILE_PDF.equalsIgnoreCase(fileType)) { String type; try { return false ; } catch (Exception e) { return true ; } } return true ; } }
DateUtil
DateUtil:从 yyyy-MM-dd HH:mm:ss
字符串中获取信息、获取当前年份。
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 public class DateUtil { public static String getHmDateStr (String date) { if (date == null || "" .equals(date)) { return null ; } else { int i = date.lastIndexOf(":" ); return date.substring(0 , i); } } public static String getYear (String date) { if (date == null || "" .equals(date)) { return null ; } else { int i = date.indexOf("-" ); return date.substring(0 , i); } } public static String getThisYear () { return String.valueOf(LocalDate.now().getYear()); } }
AssertUtil
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 public class AssertUtil { public static void dataNotNull (Object o, String message) { if (o == null ) { throw new BusinessException (ErrorCode.DATA_IS_NULL).addError(message); } } public static void throwBusinessException (boolean condition, String code, String msg) { if (condition) { throw new BusinessException (code).addError(msg); } } }
8. 后端 - 其他
一般都是使用 @DateTimeFormat
把传给后台的时间字符串转成 Date
,使用 @JsonFormat
把后台传出的 Date
转成时间字符串。
但是 @DateTimeFormat
只会在类似 @RequestParam
的请求参数(url拼接的参数才生效,如果是放到RequestBody中的form-data是无效的)上生效,即:@DateTimeFormat
放到 @RequestBody
下是无效的。
在 @RequestBody
中则可以使用 @JsonFormat
把传给后台的时间字符串转成Date,也就是说 @JsonFormat
其实既可以把传给后台的时间字符串转成Date也可以把后台传出的 Date
转成时间字符串。
8.2 Excel 的导出
在此以联系方式的导出为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public Result<Void> exportContact (HttpServletResponse response) throws IOException { try { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ); response.setCharacterEncoding("utf-8" ); String fileName = java.net.URLEncoder.encode("联系方式" , "UTF-8" ) .replaceAll("\\+" , "%20" ); response.setHeader("Content-Disposition" , "attachment;filename*=utf-8''" + fileName + ".xlsx" ); List<ContactExportDto> list = this .getContactExportDtoList(); AssertUtil.throwBusinessException(list.size() == 0 , ErrorCode.EMPTY_CONTACT_INFO, "暂无可导出的联系方式信息" ); EasyExcel.write(response.getOutputStream(), ContactExportDto.class).autoCloseStream(Boolean.FALSE).sheet() .doWrite(list); } catch (Exception e) { ExcelExportResponse.exportFailedResponse(response, e); } return Result.success(); }
Excel 导出失败的响应信息:
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 @Slf4j public class ExcelExportResponse { public static void exportFailedResponse (HttpServletResponse response, Exception e) throws IOException { response.reset(); response.setContentType("application/json" ); response.setCharacterEncoding("utf-8" ); Result<Object> result = null ; if (e instanceof BusinessException) { log.error(((BusinessException) e).code() + " : " + e.getMessage()); result = new Result <>(((BusinessException) e).code(), e.getMessage()); } else { String defaultMessage = "Excel导出失败" ; log.error(ErrorCode.EXCEL_EXPORT_ERROR + " : " + defaultMessage); result = new Result <>(ErrorCode.EXCEL_EXPORT_ERROR, defaultMessage); } response.setHeader("Access-Control-Allow-Origin" , "*" ); response.getWriter().print(new ObjectMapper ().writeValueAsString(result)); } }
9. 前端 - JS 的封装
9.1 http.js
由于后端使用了 JWT 和 SpringSecurity,要求前端发送的请求都要在请求头中添加上 Token,因此需要对前端使用的 Axios 进行配置,比如添加拦截器。
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 import axios from 'axios' ;import router from '../router' import Vue from '../main' axios.defaults .baseURL = 'http://127.0.0.1:9090' axios.interceptors .request .use (config => { if (config.url === '/auth/login' ) { return config; } const token = window .localStorage .getItem ("token" ); if (token != null && token != '' ) { config.headers ['Authorization' ] = token; } else { } return config; }, err => { return Promise .reject (err); }) axios.interceptors .response .use (res => { verifyUserLoginStatus (res); return res; }, err => { return Promise .reject (err); }) export default axios;
为了更好地使用 Element-UI 中的 Message(消息提示)组件,在这里对这个组件的使用进行简单的封装。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 function showMsg (_this, msg, type, duration ) { if (duration == null ) { duration = 1500 ; } if (msg == null ) return ; _this.$message({ message : msg, type : type, duration : duration }) } const Tools = { showMsg } export default Tools ;
9.3 file.js
对于文件的上传来说,除了在后端进行类型校验外,还需要在前端对文件类型进行校验。这里只对 Excel、PDF 类型的文件进行了类型校验:
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 import Tools from './tools' export function verifyExcelTypeAndSize (_this, file ) { let fileType = getFileType (file); let isExcel = (fileType === "xls" ) || (fileType === "xlsx" ); let isLt5M = file.size / 1024 / 1024 < 5 ; if (!isExcel) { Tools .showMsg (_this, "上传文件只能是Excel文件格式" , "error" , 4000 ); return false ; } if (!isLt5M) { Tools .showMsg (_this, "上传文件大小不超过5M" , "error" , 4000 ); return false ; } return true ; } export function verifyPdfTypeAndSize (_this, file ) { let fileType = getFileType (file); let isExcel = (fileType === "pdf" ); let isLt10M = file.size / 1024 / 1024 < 10 ; if (!isExcel) { Tools .showMsg (_this, "上传文件只能是PDF文件格式" , "error" , 4000 ); return false ; } if (!isLt10M) { Tools .showMsg (_this, "上传文件大小不超过10M" , "error" , 4000 ); return false ; } return true ; } function getFileType (file ) { let fileName = file.name ; let startIndex = fileName.lastIndexOf ('.' ); if (startIndex != -1 ) { return fileName.substring (startIndex + 1 , fileName.length ).toLowerCase (); } else { return '' ; } }
9.4 date.js
将 JS 中 Date 类型的格式转换成 yyyy-MM-dd HH:mm:ss
格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export function renderTime (date ) { if (date != null && date != '' ) { var dateJson = new Date (date).toJSON (); return new Date (+new Date (dateJson) + 8 * 3600 * 1000 ).toISOString ().replace (/T/g , ' ' ).replace (/\.[\d]{3}Z/ , '' ) } else { return '' } }
在浏览器中的使用结果:
9.5 request.js
在进行删除等敏感操作时,前端应当显示确认框,当用户点击“确定”后才进行敏感操作。
通过点击按钮实现 Excel 的下载时,只需要改变请求地址即可,因此也对 Excel 的下载进行封装。
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 60 61 62 63 64 65 66 67 68 69 70 71 72 import axios from 'axios' ;import Tools from "../utils/tools" export function sensitiveOperation (_this, warningMsg, func ) { _this.$confirm(warningMsg, "提示" , { confirmButtonText : "确定" , concelButtonText : "取消" , type : "warning" , }) .then (() => { func () }) .catch (() => {}); } export function exportExcel (_this, url ) { axios .get (url, { responseType : "blob" }) .then ((res ) => { let type = res.data .type ; const relType = [ "application/vnd.ms-excel" , "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" , ]; if (type.includes ("application/json" )) { let reader = new FileReader (); reader.onload = function (event ) { let errorResult = JSON .parse (reader.result ); let msg = Tools .errMsg (errorResult.code , errorResult.msg ); Tools .showMsg (_this, msg, "error" , 3000 ); }; reader.readAsText (res.data ); } if (relType.includes (type)) { let blob = new Blob ([res.data ]); let downloadElement = document .createElement ("a" ); let href = window .URL .createObjectURL (blob); downloadElement.href = href; let contentDisposition = res.headers ["content-disposition" ]; let fileName = "undefine.xlsx" ; if (contentDisposition) { fileName = window .decodeURI ( res.headers ["content-disposition" ].split ("''" )[1 ], "UTF-8" ); } downloadElement.download = fileName; document .body .appendChild (downloadElement); downloadElement.click (); document .body .removeChild (downloadElement); window .URL .revokeObjectURL (href); } }) .catch ((err ) => { Tools .showMsg (this , "Excel导出失败!" , "error" ); }); }
9.6 validate.js
使用 Element-UI 提交 Dialog 组件中的表单数据时,需要对表单中的数据有效性进行校验,比如对电话号码格式、邮箱格式进行校验。如果校验不通过,应当在所在位置显示提示信息,当关闭 Dialog 时,还需要清空校验信息。
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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 export function formValidate (_this, refName, errMsg, func ) { _this.$refs [refName].validate ((valid ) => { if (valid) { func () } else { _this.$message({ message : errMsg, type : "error" , duration : 3000 , }); return false ; } }); } export function resetForm (_this, refName ) { _this.$refs [refName].resetFields (); } export const passwordValidate = (rule, value, callback, errMsg ) => { const passwordReg = /^[a-zA-Z]\w{4,15}$/ ; if (!value) { if (errMsg != null && errMsg != '' && errMsg != undefined ) { return callback (new Error (errMsg)) } return callback (new Error ('密码不能为空' )) } setTimeout (() => { if (passwordReg.test (value)) { callback () } else { callback (new Error ('以字母开头,长度在5~16之间,只能包含字母、数字和下划线' )) } }, 100 ) } export const accountValidate = (rule, value, callback ) => { const accountReg = /^[a-zA-Z0-9][a-zA-Z0-9_]{4,15}$/ ; if (!value) { return callback (new Error ('账号不能为空' )) } setTimeout (() => { if (accountReg.test (value)) { callback () } else { callback (new Error ('以字母或数字开头,长度在5~16之间,允许字母、数字和下划线' )) } }, 100 ) } export const phoneNumValidate = (rule, value, callback, notNull ) => { const phoneReg = /^(13[0-9]|14[5|7]|15[0|1|2|3|4|5|6|7|8|9]|18[0|1|2|3|5|6|7|8|9])\d{8}$/ ; if (!value) { if (notNull) { return callback (new Error ('电话号码不能为空' )) } return callback () } setTimeout (() => { if (!Number .isInteger (+value)) { callback (new Error ('请输入数字值' )) } else { if (phoneReg.test (value)) { callback () } else { callback (new Error ('电话号码格式不正确' )) } } }, 100 ) } export const faxNumValidate = (rule, value, callback, notNull ) => { const faxReg = /\d{3}-\d{8}|\d{4}-\d{7}/ ; if (!value) { if (notNull) { return callback (new Error ('传真电话号码不能为空' )) } return callback () } setTimeout (() => { if (faxReg.test (value)) { callback () } else { callback (new Error ('请输入正确格式的传真电话号码' )) } }, 100 ) } export const emailValidate = (rule, value, callback, notNull ) => { const mailReg = /^[A-Za-z\d]+([-_.][A-Za-z\d]+)*@([A-Za-z\d]+[-.])+[A-Za-z\d]{2,4}$/ ; if (!value) { if (notNull) { return callback (new Error ('邮箱不能为空' )) } return callback () } setTimeout (() => { if (mailReg.test (value)) { callback () } else { callback (new Error ('请输入正确格式的邮箱' )) } }, 100 ) } export const identityNumValidate = (rule, value, callback, notNull ) => { const mailReg = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/ ; if (!value) { if (notNull) { return callback (new Error ('身份证号码不能为空' )) } return callback () } setTimeout (() => { if (mailReg.test (value)) { callback () } else { callback (new Error ('请输入正确格式的身份证号码' )) } }, 100 ) }
9.7 index.js
路由前置守卫:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 router.beforeEach ((to, from , next ) => { if (to.meta .title ) { document .title = to.meta .title ; } if (to.path === '/' ) return next (); const token = window .localStorage .getItem ('token' ); if (token === null || token === '' ) { return next ('/' ); } next (); })
10. Element-UI 的使用
10.1 在表单指定字段下提示错误信息
比如,在上传考试科目信息时,对考试科目类型进行校验,并且考试科目类型字段所在的输入框类型为下拉框。
先在 data
中指定错误信息:
1 2 3 4 5 6 data ( ) { return { examTypeError : null , } }
然后在 el-form-item
组件上使用 error
属性,绑定错误信息:
1 2 3 4 5 6 <el-form-item :error="examTypeError"> <el-select @blur="selectValueValidate" @change="selectValueValidate" ></el-select> </el-form-item>
由于使用 Element-UI 自带的校验对 Select 组件进行校验时无法准确地显示错误信息,因此在此使用事件的方式进行校验,并显示错误信息。
最后编写错误校验方法即可:
1 2 3 4 5 6 7 8 selectValueValidate ( ) { if (this .subjectForm .examType == null ) { this .examTypeError = Math .random ().toString (); this .$nextTick(() => { this .examTypeError = "考试科目类型不能为空" ; }); } }
<el-form-item>
的 error
属性监听的是 watch
方法,也就是两次重复的错误操作,提示的错误信息一致,就不会触发 watch 方法。
Element-UI 表单组件规定一旦表单验证通过就会清除原来的错误提示信息,这样就导致了在进行第二次验证时原来的错误信息被清除了,而两次错误信息又一致没法触发 watch
方法,所以也不会重新渲染出新的错误信息出来。
解决方法很简单:使用 $nextTick
来修复这个问题。
vm.$nextTick
可以将回调延迟到下次 DOM 更新循环之后执行。在修改数据之后立即使用它,然后等待 DOM 更新。我们知道错误提示不显示的根本原因是 watch 方法没有被触发。那如果我每次给错误赋值一个随机值,然后使用 $nextTick
在 DOM 被更新之后将随机值改成我们需要的错误信息就可以了。
10.2 el-select 设置 multiple 后出现的问题
设置 <el-select>
的 multiple
属性,并为该组件绑定 Element UI 的表单验证后,会出现 一进界面就校验失败的错误提示信息 ,为解决这个问题,可以在当前所在 Vue 的生命周期函数中添加如下代码:
1 2 3 4 created ( ) { this .$set(this .userForm , "roleList" , []); },
其中,this.userForm
是 data 域中的表单数据,如:
1 2 3 4 5 data ( ) { return { userForm : {}, } }
而 roleList
是在使用校验时,Element UI <el-select>
组件所在的 <el-form-item>
组件绑定的 prop
的值,如:
1 2 3 4 5 <el-form-item prop="roleList"> <el-select v-model="userForm.roleList" multiple > <!----> </el-select> </el-form-item>
当 <el-select>
组件被设置了 multiple
属性后,其绑定的数据类型将会变为一个数组,因此需要将其设置为 []
。
而出现这种问题的原因也很简单,当 <el-select>
组件被设置了 multiple
属性后,初次进入这个这件的所在的页面时,将会自动触发 <el-select>
组件的 change
事件,而由于我们 data
域中表单数据初始化为 userForm: {}
,Element UI 就会认定值缺失,并显示校验失败的错误提示信息。
补充
而我在使用时,是在 Element UI dialog 对话框中使用了 <el-select>
组件,并且将这个对话框写在了另一个 vue
文件中。
为了在第二次打开 dialog 时,将绑定数据和校验信息清空,因此为 <el-dialog>
组件绑定了 close
方法,使其在关闭 dialog 时,可以清空绑定数据和校验信息清空。如:
1 <el-dialog @close="closeSaveOrUpdateDialog">
1 2 3 4 5 6 closeSaveOrUpdateDialog ( ) { this .userForm = {}; resetForm (this , "userForm" ); this .$set(this .userForm , "roleList" , []); },
注意,由于这里直接将 this.userForm
设置为 {}
,又出现了前面的问题,因此在这方法最后也要设置一下。
否则就会出现以下情况:第一次打开 dialog 不会触发异常校验,啥都不干,再次打开时就又会触发异常校验。
如果 <el-select>
组件被设置了 multiple
属性后,出现了数据无法回显,或数据无法编辑的情况,可以为 <el-select>
组件绑定 change
事件,并在这个事件绑定的方法中执行:
1 2 3 changeSelect ( ) { this .$forceUpdate() }
这是因为由于一些嵌套特别深的数据,导致数据更新了,但是页面却没有重新渲染,需要使用 this.$forceUpdate()
迫使 Vue 实例重新渲染。
经验之谈
针对页面上 data 域中同一数据的绑定,最好使用一次请求完成,而不是向后台发送多次请求。
比如,data 域中有一如下数据:
1 2 3 4 5 data ( ) { return { userForm : {}, } }
很明显,userForm
是一个对象,这个对象中可能有多个属性,当我们向后台发送请求,为这个对象绑定数据时,最好是在一次请求完成后就为这个对象绑定数据。而不是先发送一次请求,绑定该对象中 A、B 属性的值,再发送一次请求绑定该对象中 C 属性的值。
11. 前端 - 其他
11.1 后端 ID 异常
由于 JS 的精度所限,当后端的 ID 信息(Long
类型)传递到前端时会出现精度缺失,因此后端应当将 Long
类型的 ID 信息转换成字符串传递给前端。
针对这种情况,后端可以在表示 ID 的字段上使用 Jackson 的注解,如:
1 2 3 4 5 @JsonSerialize(using = ToStringSerializer.class) private Long id;
11.2 Token 信息的存储
用户成功登录后,后端会将当前用户的 Token 信息存储在响应头中,前端应当拿到这个 Token 信息,并在每次向后端发送请求时携带这个 Token 信息用于用户验证。
获取响应头中 Token 信息的方式:
1 window .localStorage .setItem ("token" , res.headers .authorization );
其中,res
为 Axios 请求发送成功后的响应体。
在这里,我将 Token 信息存储到 localStorage
中,当然也可以存储到 sessionStorage
中,这两者与 cookie
的区别如下:
1、cookie 数据始终在同源的 http 请求中携带(即使不需要),即 cookie 在浏览器和服务器间来回传递,而 sessionStorage 和 localStorage 不会自动把数据发送给服务器,仅在本地保存。cookie 数据还有路径(path)的概念,可以限制 cookie 只属于某个路径下
2、存储大小限制也不同。cookie 数据不能超过 4K,同时因为每次 http 请求都会携带 cookie,所以 cookie 只适合保存很小的数据,如会话标识。sessionStorage 和 localStorage 虽然也有存储大小的限制,但比 cookie 大得多,可以达到 5M 或更大
3、数据有效期不同。sessionStorage 仅在当前浏览器窗口关闭之前有效;localStorage 始终有效,窗口或浏览器关闭也一直保存,因此用作持久数据;cookie 只在设置的 cookie 过期时间之前有效,即使窗口关闭或浏览器关闭
4、作用域不同。sessionStorage 不在不同的浏览器窗口中共享,即使是同一个页面;localstorage 在所有同源窗口中都是共享的;cookie 也是在所有同源窗口中都是共享的
5、web Storage 支持事件通知机制,可以将数据更新的通知发送给监听者
6、web Storage 的 api 接口使用更方便
localStorage
的使用方式很简单,参考【localStorage 使用总结】一文。
12. 结语
以上就是我本次毕业设计一个简单的总结,由于代码量比较大,可能有些地方并不完善,如果有不完善的地方后续再补充吧。
回望整个过程,其实这次毕业设计在技术领域上并不难,难的是某些功能的实现逻辑。纵观整个后端代码,由于我初期的设计问题,造成了很多代码冗余(当然,这个问题在前端上更为严重),因此在代码的设计与整洁方面还需要更多的学习。
但庆幸的是,因为本次毕业设计是从 0 开始(无论前端还是后端),从可行性分析一步步走到测试,在这期间还是学到了很多东西,也没枉费我两个月的实习工资。
从本文的封面可以看出,我将我的指导老师姓名添加了上去(虽然你并不认识,但这没有关系),再次向郭老师表示由衷的感谢。本次毕业设计的选题是由我的指导老师所给定的,感谢郭老师在系统前期设计时给予我众多的宝贵意见与参考,指导我分析、设计、实现整个系统。在论文撰写方面尽心尽责的为我讲解撰写规范与注意事项,并对我的论文进行了仔细、反复的检查,准确得给出修改意见,正是因为有郭老师的帮助,系统与论文才得以顺利完成。
我还将我大学四年所在的班级也添加了上去,以此纪念我逝去的大学时光。