48. 在父项目中管理子模块项目使用的依赖
在一个项目中,如果某些依赖只是部分子模块项目需要使用的,应该将这些依赖配置在<dependencyManagement>
节点中,凡配置在这个节点中的依赖,任何子模块项目中都不会直接拥有,如果某个子模块项目需要使用这些依赖,依然需要使用<dependency>
节点来添加!与在子模块项目中直接添加<denpendency>
(父级的<dependencyManagement>
没有配置某个依赖而子模块项目中直接添加)的区别在于:如果事先使用父级项目的<dependencyManagement>
进行了配置,则子模块项目在添加时,不需要指定版本号,直接使用父级项目配置的版号,以便于在父级项目中统一管理依赖的版本!
注意:在父级项目中,添加许多依赖都是不需要指定版本号的,但是,如果将这些依赖配置到<dependencyManagement>
中用于指导子模块项目所使用的依赖的版本时,必须显式的指定版本号,否则,子模块项目将不明确需要使用的是哪个版本!
则在父级项目中,关于依赖的管理:
代码语言:javascript复制<properties>
<!-- Java Version -->
<java.version>1.8</java.version>
<!-- Dependency Version -->
<mysql.version>8.0.20</mysql.version>
<mybatis.version>2.1.3</mybatis.version>
<mybatis.plus.version>3.3.2</mybatis.plus.version>
<druid.version>1.1.23</druid.version>
<pagehelper.version>1.2.13</pagehelper.version>
<thymeleaf.springsecurity5.version>3.0.4.RELEASE</thymeleaf.springsecurity5.version>
<spring.boot.starter.version>2.3.1.RELEASE</spring.boot.starter.version>
<lombok.version>1.18.12</lombok.version>
</properties>
<!-- 直接添加在dependencies节点的中的依赖是每个子模块项目都直接拥有的 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.starter.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<!-- 添加在dependencyManagement中的依赖只是为了管理子模块项目使用依赖时的版本 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>${pagehelper.version}</version>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
<version>${thymeleaf.springsecurity5.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
<version>${spring.boot.starter.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring.boot.starter.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>${spring.boot.starter.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>${druid.version}</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
<version>${lombok.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
由于大量的依赖都已经添加在<dependencyManagement>
中了,则straw-generator
和straw-portal
项目都不会直接拥有这些依赖,则需要在这2个子模块项目中自行添加所需的依赖!
在straw-generator
项目中(关于代码生成器的相关依赖由于过于特殊,一定只有当前项目需要使用,所以,对版本的管理方式可以不严格要求):
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>3.3.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
<version>2.3.1.RELEASE</version>
</dependency>
</dependencies>
在straw-portal
项目中:
<dependencies>
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
50. 创建静态资源子模块项目
创建新的straw-resource
子模块项目,用于管理用户上传的文件等静态资源。
创建出来后,在straw-resource
的pom.xml
中,自行将父级项目由SpringBoot
改为straw
项目,删除<dependencies>
和<build>
节点(因为没有存在的必要,在父项目中已经配置好了)。
在straw
项目中的<mudules>
中添加子模块项目。
在straw-resource
的application.properties
中显式的配置端口号,必须与straw-portal
的不同:
server.port=8081
全部完成后,更新Maven,straw-portal
和straw-resource
这2个项目是可以同时启动的!
51. 设置straw-resource子模块项目的静态目录
在straw-resource
项目的application.properties
中添加配置:
spring.resources.static-locations=file:D:/IdeaProjects/straw-static-resource
则straw-resource
项目的静态目录就是以上指定的位置,后续straw-portal
项目中涉及上传操作时,上传的文件也应该存放到以上位置。
52.设置straw-resource子模块项目的静态目录
在straw-portal
项目的application.properties
中添加配置:
# 发布问题时,将图片上传到哪里,需要与straw-resource项目的静态资源目录保持一致
project.question.image-upload-path=D:/IdeaProjects/straw-static-resource
# 发布问题时,上传的图片通过哪个服务器提供访问,配置的端口号需要与straw-resource项目保持一致
project.question.image-host=http://localhost:8081/
# 发布问题时,允许上传的文件的最大大小
project.question.image-max-size=307200
# 发布问题时,允许上传的图片文件的类型
project.question.image-content-types=image/jpeg, image/png, image/bmp
并且,在straw-portal
中调整默认限制的文件大小:
@Bean
public MultipartConfigElement multipartConfigElement() {
MultipartConfigFactory factory = new MultipartConfigFactory();
factory.setMaxFileSize(DataSize.ofMegabytes(500));
factory.setMaxRequestSize(DataSize.ofMegabytes(500));
return factory.createMultipartConfig();
}
53. 开发简易上传功能
说明:由于上传功能不可以通过在URL上填写参数直接进行测试,为了更快的进行测试并体验上传的效果,暂且忽略不必要的代码,例如上传文件的相关检查等细节问题,当然,测试时也应该使用正确的文件和数据进行测试。当简单的上传已经完成后,再补全细节部分。
在QuestionController
中开发服务器端的简易上传处理:
@Value("${project.question.image-upload-path}")
private String imageUploadPath;
@Value(("${project.question.image-host}"))
private String imageHost;
@PostMapping("/upload-image")
public R<String> uploadImage(MultipartFile imageFile) {
File dest = new File(imageUploadPath, "1.jpg");
try {
imageFile.transferTo(dest);
} catch (IOException e) {
e.printStackTrace();
}
String imageUrl = imageHost "1.jpg"; // http://localhost:8081/1.jpg
log.debug("image url >>> {}", imageUrl);
return R.ok(imageUrl);
}
本次需要处理的页面是“发表问题”的question/create.html,在发表问题时,使用的富文本编辑Summernote提供了名为callbacks
的回调机制,其中,存在名为onImageUpload
的回调属性,该属性值是函数,所以,可以自定义函数配置到这个回调属性中,则后续上传图片时,就会自动触发自定义的函数,通过自定义函数实现图片的上传,并返回上传图片的URL,生成图片插入到Summernote富文本编辑器中即可。
在question/create.html中,先将底部关于Summernote的JavaScript代码移到新创建的commons/init_summernote.js中,并调整这段代码:
代码语言:javascript复制$(document).ready(function () {
$('#summernote').summernote({
height: 300,
tabsize: 2,
lang: 'zh-CN',
placeholder: '请输入问题的详细描述...',
callbacks: {
onImageUpload: function () {
alert("准备上传图片!");
}
}
});
});
完成后,重启项目,打开“发布问题”页面,插入图片,选择图片文件就会弹出对话框!
然后,在以上回调中,使用$.ajax()
提交异步请求,在处理结果时,创建Image
对象,将结果中的图片URL作为Image
对象的src
属性值,并将整个Image
对象(就是一个<src>
标签)插入到富文本编辑器中:
$(document).ready(function () {
$('#summernote').summernote({
height: 300,
tabsize: 2,
lang: 'zh-CN',
placeholder: '请输入问题的详细描述...',
callbacks: {
onImageUpload: function (files) {
// ---------------------------------------
// 当前函数的参数名称是自定义,它表示用户选择的若干个文件
// Summernote在调用该函数时,会把用户选择的文件作为函数的参数
// ---------------------------------------
if (!files || files.length < 1) {
alert("请选择您要上传的文件!");
return;
}
if (files.length > 1) {
alert("一次只允许上传1个文件!");
return;
}
let formData = new FormData();
let file = files[0];
formData.append("imageFile", file);
console.log("form data >>> " formData);
$.ajax({
url: '/api/v1/questions/upload-image',
type: 'post',
data: formData,
contentType: false,
processData: false,
success: function(json) {
if (json.state == 2000) {
// alert(json.data);
let img = new Image(); // <img>
img.src = json.data; // <img src="xxx">
$('#summernote').summernote('insertNode', img);
} else {
alert(json.message);
}
}
});
}
}
});
});
54. 完善服务器端的上传功能
先创建关于文件上传的异常类型:
代码语言:javascript复制public class FileUploadException extends RuntimeException {
}
代码语言:javascript复制public class FileEmptyException extends FileUploadException {
}
代码语言:javascript复制public class FileSizeException extends FileUploadException {
}
代码语言:javascript复制public class FileTypeException extends FileUploadException {
}
代码语言:javascript复制public class FileIOException extends FileUploadException {
}
在GlobalExceptionHandler
中处理以上异常,完整代码如下(需在R.State
中添加常量):
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler
public R handleException(Throwable e) {
if (e instanceof ParameterValidationException) {
return R.failure(R.State.ERR_PARAMETER_INVALIDATION, e);
} else if (e instanceof InviteCodeException){
return R.failure(R.State.ERR_INVITE_CODE, e);
} else if (e instanceof ClassDisabledException) {
return R.failure(R.State.ERR_CLASS_DISABLED, e);
} else if (e instanceof PhoneDuplicateException) {
return R.failure(R.State.ERR_PHONE_DUPLICATE, e);
} else if (e instanceof InsertException) {
return R.failure(R.State.ERR_INSERT, e);
} else if (e instanceof FileEmptyException) {
return R.failure(R.State.ERR_UPLOAD_EMPTY, e);
} else if (e instanceof FileSizeException) {
return R.failure(R.State.ERR_UPLOAD_FILE_SIZE, e);
} else if (e instanceof FileTypeException) {
return R.failure(R.State.ERR_UPLOAD_FILE_TYPE, e);
} else if (e instanceof FileIOException) {
return R.failure(R.State.ERR_UPLOAD_FILE_IO, e);
} else if (e instanceof AccessDeniedException) {
return R.failure(R.State.ERR_ACCESS_DENIED, e);
} else {
log.debug("Unknown Exception", e);
return R.failure(R.State.ERR_UNKNOWN, e);
}
}
}
在处理上传请求之前,先声明2个全局属性,用于读取配置中的“文件最大大小”和“文件类型”:
代码语言:javascript复制@Value("${project.question.image-max-size}")
private long imageMaxSize;
@Value(("${project.question.image-content-types}"))
private List<String> imageContentTypes;
在处理上传请求的过程中:
- 应该创建子级文件夹,避免所有的文件都传到指定的同一个文件夹中,推荐使用“年”和“月”分别创建2级子文件夹,上传的图片应该放在“月”的文件夹中;
- 可以使用UUID作为文件名;
- 不需要判断原始扩展名,而是直接从原始文件全名中截取即可;
- 及时打桩,输出关键信息,例如保存文件的文件夹路径、文件名、完整路径等,便于出错时排查问题。
具体代码:
代码语言:javascript复制@Value("${project.question.image-upload-path}")
private String imageUploadPath;
@Value(("${project.question.image-host}"))
private String imageHost;
@Value("${project.question.image-max-size}")
private long imageMaxSize;
@Value(("${project.question.image-content-types}"))
private List<String> imageContentTypes;
@PostMapping("/upload-image")
public R<String> uploadImage(MultipartFile imageFile) {
// 判断上传的文件是否为空
if (imageFile.isEmpty()) {
throw new FileEmptyException("上传图片失败!请选择有效的图片文件!");
}
// 判断上传的文件大小是否超标
if (imageFile.getSize() > imageMaxSize) {
throw new FileSizeException("上传图片失败!不允许使用超过" (imageMaxSize / 1024) "KB的图片文件!");
}
// 判断上传的文件类型是否超标
if (!imageContentTypes.contains(imageFile.getContentType())) {
throw new FileTypeException("上传图片失败!图片类型错误!允许上传的图片类型有:" imageContentTypes);
}
// 确定本次上传时使用的文件夹
String dir = DateTimeFormatter.ofPattern("yyyy/MM").format(LocalDateTime.now());
File parent = new File(imageUploadPath, dir);
if (!parent.exists()) {
parent.mkdirs();
}
log.debug("dir >>> {}", parent);
// 确定本次上传时使用的文件名
String filename = UUID.randomUUID().toString();
String originalFilename = imageFile.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
String child = filename suffix;
// 创建最终保存时的文件对象
File dest = new File(parent, child);
// 执行保存
try {
imageFile.transferTo(dest);
} catch (IOException e) {
throw new FileIOException("上传图片失败!当前服务器忙,请稍后再次尝试!");
}
// 确定网络访问路径
String imageUrl = imageHost dir "/" child; // http://localhost:8081/1.jpg
log.debug("image url >>> {}", imageUrl);
// 返回
return R.ok(imageUrl);
}
55. 显示老师主页
老师的主页文件是index_teacher.html
,原本在static
文件夹中,先把它拖拽到templates
文件夹中。
在SystemController
中,修改原有访问/index.html
路径的处理方法:
@GetMapping("/index.html")
public String index(@AuthenticationPrincipal UserInfo userInfo) {
if (userInfo.getType() == 0) {
return "index";
} else {
return "index_teacher";
}
}
需要注意:以上判断用户身份时,会判断用户数据的type
属性,此前,在UserServiceImpl.login()
方法中已经向返回的UserInfo
中设置了从数据库中读取到的type
属性,则以上代码可以正常获取type
值!
56. 老师主页显示问题列表-持久层
(a) 规范需要执行的SQL语句
老师主页显示的问题列表应该显示出老师自己发表的问题,和学生指定该老师回答的问题。
这样的列表数据可以使用此前的QuestionVO
来表示每一个问题的数据,列表则使用List<QuestionVO>
来表示。
需要执行的SQL语句大致是:
代码语言:javascript复制select question.*
from question
left join user_question
on question.id=user_question.question_id
where question.user_id=? or user_question.user_id=? and is_delete=0
order by status, modified_time desc;
(b) 在接口中添加抽象方法
代码语言:javascript复制List<QuestionVO> findTeacherQuestions(Integer teacherId);
© 配置SQL映射
代码语言:javascript复制
(d) 单元测试
代码语言:javascript复制
57. 老师主页显示问题列表-业务层
(a)
(b) 接口与抽象方法
原本存在抽象方法:
代码语言:javascript复制PageInfo<QuestionVO> getQuestionsByUserId(Integer userId, Integer page);
改为:
代码语言:javascript复制PageInfo<QuestionVO> getQuestionsByUserId(Integer userId, Integer type, Integer page);
© 实现业务方法
在原本存在的getQuestionsByUserId()
方法的参数列表中添加参数,与以上抽象方法保持一致,然后,在实现过程中:
// 设置分页参数
PageHelper.startPage(page, pageSize);
// 调用持久层方法查询问题列表,该列表中的数据只有标签的id,并不包括标签数据
List<QuestionVO> questions;
if (type == 0) {
questions = questionMapper.findListByUserId(userId);
} else {
questions = questionMapper.findTeacherQuestions(userId);
}
// 后续代码不变
(d) 单元测试
由于修改了业务方法的声明,当前控制器层的调用会因为参数不匹配而报错,将无法进行单元测试,所以,先处理完控制器层再测试。
58. 老师主页显示问题列表-控制器层
在原来的获取学生问题列表的方法中,调用业务方法时多添加type
值即可,该值来自UserInfo
参数:
@GetMapping("/my")
public R<PageInfo<QuestionVO>> getMyQuestions(Integer page,
@AuthenticationPrincipal UserInfo userInfo) {
if (page == null || page < 1) {
page = 1;
}
PageInfo<QuestionVO> questions = questionService.getQuestionsByUserId(userInfo.getId(), userInfo.getType(), page);
return R.ok(questions);
}
完成后,应该分别测试学生账号登录后显示列表和老师账号登录后显示列表。
59. 老师主页显示问题列表-前端页面
引用question/create.html
中的处理即可!也就是说:在question/create.html
中将列表区域设置为th:fragment
,然后在index_teacher.html
中通过th:replace
直接引用即可!