👉 这是一个或许对你有用的社群
🐱 一对一交流/面试小册/简历优化/求职解惑,欢迎加入「芋道快速开发平台」知识星球。下面是星球提供的部分资料:
《项目实战(视频)》:从书中学,往事中“练” 《互联网高频面试题》:面朝简历学习,春暖花开 《架构 x 系统设计》:摧枯拉朽,掌控面试高频场景题 《精进 Java 学习指南》:系统学习,互联网主流技术栈 《必读 Java 源码专栏》:知其然,知其所以然
👉这是一个或许对你有用的开源项目
国产 Star 破 10w+ 的开源项目,前端包括管理后台 + 微信小程序,后端支持单体和微服务架构。
功能涵盖 RBAC 权限、SaaS 多租户、数据权限、商城、支付、工作流、大屏报表、微信公众号等等功能:
Boot 仓库:https://gitee.com/zhijiantianya/ruoyi-vue-pro Cloud 仓库:https://gitee.com/zhijiantianya/yudao-cloud 视频教程:https://doc.iocoder.cn 【国内首批】支持 JDK 21 + SpringBoot 3.2.2、JDK 8 + Spring Boot 2.7.18 双版本
来源:juejin.cn/post/
7416267311848816675
传统查询逻辑的问题
在代码评审的过程中,发现有些查询逻辑可读性太差,但是一时又没找到好的方法。复杂的查询往往需要查询大量的信息。例如下面的例子,获取测验的正确率和参与率。还有测验每道题目分别的正确率和参与率。
老师可以在课堂 (Classroom
) 上发起测验 (Exam
),一个测验对应多道题目 (Question
),学生 (Student
) 可以对题目进行回答 (StudentQuestionAnswer
)
public class ExamInfoQuestionQueryService {
private ExamQuestionMapper questionMapper;
private StudentQuestionAnswerMapper studentQuestionAnswerMapper;
private ExamMapper examMapper;
private StudentMapper studentMapper;
/**
* 获取测验和题目的正确率和参与率
*
*/
public ExamDto getQuestionByExamId(String examId) {
Exam exam = examMapper.selectById(examId);
// 获取所有题目
List<Question> questions = questionMapper.selectByExamId(examId);
List<String> examQuestionIds = questions.stream().map(Question::getId).collect(Collectors.toList());
// 获取所有学生的答题情况
List<StudentQuestionAnswer> studentQuestionAnswers = studentQuestionAnswerMapper.selectByExamQuestionIds(examQuestionIds);
Map<String, List<StudentQuestionAnswer>> questionMap = studentQuestionAnswers.stream().collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId));
// 获取所有学生, 为了获取学生数
List<Student> students = studentMapper.selectByClassroom(exam.getClassroomId());
ExamDto examDto = new ExamDto();
// 计算测验正确率
examDto.setCorrectRate(calculateCorrectCount(studentQuestionAnswers));
// 计算测验参与度
examDto.setSubmittedRate(calculateSubmittedRate(studentQuestionAnswers,students));
List<QuestionDto> questionDtos = questions.stream().map(question -> {
QuestionDto questionDto = new QuestionDto();
// 计算题目正确率
List<StudentQuestionAnswer> questionAnswers = questionMap.getOrDefault(question.getId(), Collections.emptyList());
questionDto.setCorrectRate(calculateCorrectCount(questionAnswers));
// 计算题目参与率
questionDto.setSubmittedRate(calculateSubmittedRate(questionAnswers, students));
return questionDto;
}).collect(Collectors.toList());
examDto.setQuestions(questionDtos);
return examDto;
}
private double calculateSubmittedRate(List<StudentQuestionAnswer> questionAnswers, List<Student> students) {
return 1.0 * questionAnswers.size() / students.size();
}
private long calculateCorrectCount(List<StudentQuestionAnswer> questionAnswers) {
long correctCount = questionAnswers.stream()
.filter(StudentQuestionAnswer::isCorrect)
.count();
return correctCount / questionAnswers.size();
}
}
上面方法的特点是需要聚合不同的数据。为了性能考虑使用了大量的 Map, 导致可维护性下降。可维护性差体现在2点:
1.变量和引用离得太远了。因为所有数据都要在组装前里面准备好,所以无法用到的时候再去获取。
2.依靠中间变量传递,难以复用。查询对象的依赖关系如下,因为相互依赖,为了性能考虑会提前算出值传进去。
例如测验的正确率和参与度,这两个指标表面没什么联系,因为底层查询公用了 studentQuestionAnswer
,所以 studentQuestionAnswer
需要提前计算了,作为参数分别传到对应的计算方法里面。
如果涉及到更多的复用变量,方法将有更多的参数。理想情况下计算测验正确率,传一个 examId 就可以了,如果是为了复用,定义成下面这种会更好一些,因为只要知道 examId 就足以可以计算出测验的正确率,这个定义复用性是最好的,虽然性能可能也是最差的,因为很多中间变量又要重新计算。
private double calculateExamCorrectRate(String examId);
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/ruoyi-vue-pro 视频教程:https://doc.iocoder.cn/video/
更优雅的编写方式
我认为更优雅的实现方式就是去掉临时变量。在<重构> 里面也有提到通过方法替代变量的形式,目标就是让逻辑更加直观,暂时不考虑性能问题。理想的实现方式如下:
public class ExamInfoQuestionQueryService2 {
private ExamQuestionMapper questionMapper;
private StudentQuestionAnswerMapper studentQuestionAnswerMapper;
private ExamMapper examMapper;
private StudentMapper studentMapper;
/**
* 获取测验和题目的正确率和参与率
*/
public ExamDto getQuestionByExamId(String examId) {
ExamDto examDto = new ExamDto();
examDto.setCorrectRate(calculateExamCorrectRate(examId));
examDto.setSubmittedRate(calculateExamSubmittedRate(examId));
examDto.setQuestions(getExamQuestions(examId).stream().map(question -> {
QuestionDto questionDto = new QuestionDto();
// 计算题目正确率
questionDto.setCorrectRate(calculateQuestionCorrectRate(examId, question));
// 计算题目参与率
questionDto.setSubmittedRate(calculateQuestionSubmittedRate(examId, question));
return questionDto;
}).collect(Collectors.toList()));
return examDto;
}
private double calculateExamSubmittedRate(String examId) {
return 1.0 * getStudentQuestionAnswers(examId).size() / getStudents(examId).size();
}
private long calculateExamCorrectRate(String examId) {
return getCorrectStudentAnswerCount(examId) / getStudentQuestionAnswers(examId).size();
}
private long getCorrectStudentAnswerCount(String examId) {
return getStudentQuestionAnswers(examId).stream()
.filter(StudentQuestionAnswer::isCorrect)
.count();
}
private List<Student> getStudents(String examId) {
return studentMapper.selectByClassroom(getExam(examId).getClassroomId());
}
private Exam getExam(String examId) {
return examMapper.selectById(examId);
}
private List<StudentQuestionAnswer> getStudentQuestionAnswers(String examId) {
return studentQuestionAnswerMapper.selectByExamQuestionIds(getExamQuestions(examId)
.stream()
.map(Question::getId)
.collect(Collectors.toList()));
}
private double calculateQuestionSubmittedRate(String examId, Question question) {
return 1.0 * getStudentQuestionAnswerPerQuestion(examId).apply(question).size() / getStudents(examId).size();
}
private long calculateQuestionCorrectRate(String examId, Question question) {
return getCorrectCountPerQuestion(examId, question) / getAnswerCount(examId, question);
}
private int getAnswerCount(String examId, Question question) {
return getStudentQuestionAnswerPerQuestion(examId).apply(question).size();
}
private long getCorrectCountPerQuestion(String examId, Question question) {
return getStudentQuestionAnswerPerQuestion(examId).apply(question).stream()
.filter(StudentQuestionAnswer::isCorrect)
.count();
}
private Function<Question, List<StudentQuestionAnswer>> getStudentQuestionAnswerPerQuestion(String examId) {
Map<String, List<StudentQuestionAnswer>> studentQuestionAnswerMap = getStudentQuestionAnswers(examId).stream()
.collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId));
return question -> studentQuestionAnswerMap.getOrDefault(question.getId(), Collections.emptyList());
}
private List<Question> getExamQuestions(String examId) {
return questionMapper.selectByExamId(examId);
}
}
去掉临时变量之后前面提到的2个问题都得以解决,不存在临时变量,需要的时候用get方法替代即可。通过函数组合的方式,没有了中间变量,方法的定义复用性更好。例如增加一个单独查询测验正确率的方法,可以直接调用 calculateCorrectRate(examId)
即可,不用再传一堆变量。
虽然方法多了很多,但每个方法都单一职责,实际上用这种方式注释都可以不用加了,因为每个方法定义都表明了方法的作用。因为方法会比较细,因此针对复杂的查询最好还是单独一个类。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/yudao-cloud 视频教程:https://doc.iocoder.cn/video/
如何解决性能问题
方法重复调用会带来性能问题,如何解决性能问题? 我们观察calculateExamSubmittedRate
和 calculateExamCorrectRate
共同调用了 getStudentQuestionAnswers
方法,函数的入参其实都是一样的。又因为查询是没有副作用的,相同的参数必定返回相同的结果,因此可以缓存下来。具体实现可以加本地缓存,通过线程变量来把函数的值存下来。解决了性能问题。最后再讨论技术实现方案。
那如果参数会变怎么办,例如 getStudentQuestionAnswerPerQuestion
里面的 question。不先通过批量查询的方式就算加缓存也要 n 次查询。这时可以从函数式编程寻找灵感。
查询函数式
用函数替代变量,看到了函数式的影子。在函数式编程里面,函数有几个特点
函数组合: 多个函数可以合并成一个函数。 引用透明: 函数的返回值只取决于输入值,也就是没有副作用。 函数缓存: 函数是闭包,内部可以存数据。 高阶函数: 函数可以作为参数或者返回值传递。 柯里化: 将接收多个参数的函数转换成一系列嵌套的单个参数的函数。
函数组合和透明引用是加缓存的基础,如果没有透明引用,是加不了缓存的。
getStudentQuestionAnswerPerQuestion
其实是传了2 个参数,一个是 examId,一个是 question。能否把函数柯里化,改成 getStudentQuestionAnswerPerQuestion(examId)(question)
呢? 好处就是通过 getStudentQuestionAnswerPerQuestion(examId)
提前把所有 question 都查出来,再利用这个函数去筛选出单个 question 的值。
/**
* 函数柯里化
*/
private Function<Question, List<StudentQuestionAnswer>> getStudentQuestionAnswerPerQuestion(String examId) {
Map<String, List<StudentQuestionAnswer>> studentQuestionAnswerMap = getStudentQuestionAnswers(examId).stream()
.collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId));
return question -> studentQuestionAnswerMap.getOrDefault(question.getId(), Collections.emptyList());
}
private long getCorrectCountPerQuestion(String examId, Question question) {
return getStudentQuestionAnswerPerQuestion(examId).apply(question).stream()
.filter(StudentQuestionAnswer::isCorrect)
.count();
}
getStudentQuestionAnswerPerQuestion
计算出所有question 的值,并封装成一个函数返回。因为是闭包,因此 studentQuestionAnswerMap
会被缓存在这个函数里面,当要用到的时候,只要调用 getStudentQuestionAnswerPerQuestion
(可以对这个函数的返回值进行缓存 ) 获取函数,再通过 apply 把question 传递进去即可,实际调用就是通过 Map 获取值,并不会每次都去计算 Map 的值,从而解决多次查询的问题。
上层方法如法炮制,最终代码如下
public class ExamInfoQuestionQueryService3 {
private ExamQuestionMapper questionMapper;
private StudentQuestionAnswerMapper studentQuestionAnswerMapper;
private ExamMapper examMapper;
private StudentMapper studentMapper;
/**
* 获取测验和题目的正确率和参与率
*/
public ExamDto getQuestionByExamId(String examId) {
ExamDto examDto = new ExamDto();
examDto.setCorrectRate(calculateExamCorrectRate(examId));
examDto.setSubmittedRate(calculateExamSubmittedRate(examId));
examDto.setQuestions(getExamQuestions(examId).stream().map(question -> {
QuestionDto questionDto = new QuestionDto();
// 计算题目正确率
questionDto.setCorrectRate(calculateQuestionCorrectRate(examId).apply(question));
// 计算题目参与率
questionDto.setSubmittedRate(calculateQuestionSubmittedRate(examId).apply(question));
return questionDto;
}).collect(Collectors.toList()));
return examDto;
}
private double calculateExamSubmittedRate(String examId) {
return 1.0 * getStudentQuestionAnswers(examId).size() / getStudents(examId).size();
}
private long calculateExamCorrectRate(String examId) {
return getCorrectStudentAnswerCount(examId) / getStudentQuestionAnswers(examId).size();
}
private long getCorrectStudentAnswerCount(String examId) {
return getStudentQuestionAnswers(examId).stream().filter(StudentQuestionAnswer::isCorrect).count();
}
private List<Student> getStudents(String examId) {
return studentMapper.selectByClassroom(getExam(examId).getClassroomId());
}
private Exam getExam(String examId) {
return examMapper.selectById(examId);
}
private List<StudentQuestionAnswer> getStudentQuestionAnswers(String examId) {
return studentQuestionAnswerMapper.selectByExamQuestionIds(getExamQuestionIds(getExamQuestions(examId)));
}
private static List<String> getExamQuestionIds(List<Question> questions) {
return questions.stream().map(Question::getId).collect(Collectors.toList());
}
private Function<Question, Double> calculateQuestionSubmittedRate(String examId) {
return question -> 1.0 * getStudentQuestionAnswerPerQuestion(examId).apply(question).size() / getStudents(examId).size();
}
private Function<Question, Double> calculateQuestionCorrectRate(String examId) {
return question -> 1.0 * getCorrectCountPerQuestion(examId).apply(question) / getAnswerCount(examId).apply(question);
}
private Function<Question, Integer> getAnswerCount(String examId) {
return question -> getStudentQuestionAnswerPerQuestion(examId).apply(question).size();
}
private Function<Question, Long> getCorrectCountPerQuestion(String examId) {
return question -> getStudentQuestionAnswerPerQuestion(examId).apply(question).stream().filter(StudentQuestionAnswer::isCorrect).count();
}
private Function<Question, List<StudentQuestionAnswer>> getStudentQuestionAnswerPerQuestion(String examId) {
Map<String, List<StudentQuestionAnswer>> studentQuestionAnswerMap = getStudentQuestionAnswers(examId).stream().collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId));
return question -> studentQuestionAnswerMap.getOrDefault(question.getId(), Collections.emptyList());
}
private List<Question> getExamQuestions(String examId) {
return questionMapper.selectByExamId(examId);
}
}
使用 kotlin 的话效果更好,因为 kotlin 原生就支持函数式,不像 java 还需要有个 Function。
class ExamInfoQuestionQueryService4 {
private lateinit var questionMapper: ExamQuestionMapper
private lateinit var studentQuestionAnswerMapper: StudentQuestionAnswerMapper
private lateinit var examMapper: ExamMapper
private lateinit var studentMapper: StudentMapper
/**
* 获取测验和题目的正确率和参与率
*/
fun getQuestionByExamId(examId: String): ExamDto {
return ExamDto(
correctRate = calculateExamCorrectRate(examId),
submittedRate = calculateExamSubmittedRate(examId),
questions = getExamQuestions(examId).map { question: Question ->
QuestionDto(
// 计算题目正确率
correctRate = calculateQuestionCorrectRate(examId)(question),
// 计算题目参与率
submittedRate = calculateQuestionSubmittedRate(examId)(question)
)
}
)
}
private fun calculateExamSubmittedRate(examId: String): Double {
return 1.0 * getStudentQuestionAnswers(examId).size / getStudents(examId).size
}
private fun calculateExamCorrectRate(examId: String): Double {
return 1.0 * getCorrectStudentAnswerCount(examId) / getStudentQuestionAnswers(examId).size
}
private fun getCorrectStudentAnswerCount(examId: String): Int {
return getStudentQuestionAnswers(examId).count { obj: StudentQuestionAnswer -> obj.isCorrect }
}
private fun getStudents(examId: String): List<Student> {
return studentMapper.selectByClassroom(getExam(examId).classroomId)
}
private fun getExam(examId: String): Exam {
return examMapper.selectById(examId)
}
private fun getStudentQuestionAnswers(examId: String): List<StudentQuestionAnswer> {
return studentQuestionAnswerMapper.selectByExamQuestionIds(getExamQuestions(examId).map(Question::getId))
}
private fun calculateQuestionSubmittedRate(examId: String): (Question) -> Double {
return { question: Question ->
1.0 * getStudentQuestionAnswerPerQuestion(examId)(question).size / getStudents(examId).size
}
}
private fun calculateQuestionCorrectRate(examId: String): (Question) -> Double {
return { question: Question ->
1.0 * getCorrectCountPerQuestion(examId)(question) / getAnswerCount(examId)(question)
}
}
private fun getAnswerCount(examId: String): (Question) -> Int {
return { question: Question ->
getStudentQuestionAnswerPerQuestion(examId)(question).size
}
}
private fun getCorrectCountPerQuestion(examId: String): (Question) -> Int {
return { question: Question ->
getStudentQuestionAnswerPerQuestion(examId)(question)
.count { obj: StudentQuestionAnswer -> obj.isCorrect }
}
}
private fun getStudentQuestionAnswerPerQuestion(examId: String): (Question) -> List<StudentQuestionAnswer> {
val studentQuestionAnswerMap = getStudentQuestionAnswers(examId)
.groupBy(StudentQuestionAnswer::getExamQuestionId)
return { question: Question ->
studentQuestionAnswerMap[question.id] ?: emptyList()
}
}
private fun getExamQuestions(examId: String): List<Question> {
return questionMapper.selectByExamId(examId)
}
}
我把上面的实现方式称为函数式查询,利用函数的方式组合和组装查询值。
缓存的实现方式
对方法实现做拦截,且类非接口,最简单的方式就是 cglib 了,cglib 可以通过继承对源方法进行拦截。
通过 BeanPostProcessor
对 bean 进行代理
@Order(Ordered.HIGHEST_PRECEDENCE)
class QueryFunctionalBeanFactory : BeanPostProcessor {
/**
* 此方法可以在其他 bean 注入这个 bean 之前对这个 bean 进行替换
*/
override fun postProcessBeforeInitialization(bean: Any?, beanName: String?): Any? {
if (bean == null) {
return null
}
val targetClass = AopUtils.getTargetClass(bean)
// QueryFunctional 是标识要缓存的类
if (targetClass.isAnnotationPresent(QueryFunctional::class.java)) {
val enhancer = Enhancer()
enhancer.setSuperclass(targetClass)
enhancer.setCallback(object : MethodInterceptor {
override fun intercept(obj: Any, method: Method, args: Array<out Any>, methodProxy: MethodProxy): Any? {
return QueryFunctionalCache.cache(method) {
return@cache methodProxy.invokeSuper(obj, args)
}
}
})
// 创建 cglib 代理
val newBean = enhancer.create()
// 把代理对象设进代理类
for (fileId in bean.javaClass.fields) {
fileId.isAccessible = true
fileId.set(newBean, fileId.get(bean))
}
return newBean
}
return bean
}
override fun postProcessAfterInitialization(bean: Any?, beanName: String?): Any? {
return bean
}
}
缓存的逻辑如下
class QueryFunctionalCache {
companion object {
/**
* 线程缓存缓存方法的返回值
*/
private var cacheDatas = object : ThreadLocal<MutableMap<Method, Any?>>() {
override fun initialValue(): MutableMap<Method,Any?> {
return mutableMapOf()
}
}
fun <R : Any?> cache(method: Method, any: () -> R): R {
var cacheMethodReturnValueMap = cacheDatas.get()
// 因为入参只有一个, 且不会变, 因此只要缓存一份就好
if (cacheMethodReturnValueMap.containsKey(method)) {
return cacheMethodReturnValueMap.get(method) as R
}
val methodReturnValue = any()
cacheMethodReturnValueMap = cacheMethodReturnValueMap ?: mutableMapOf()
cacheMethodReturnValueMap.putAll(mutableMapOf(Pair(method, methodReturnValue)))
return methodReturnValue
}
}
}
上面的代码有个缺陷,同时也函数式查询的一个不足,就是入参必须不变。上面的例子大部分方法都是 examId,每次查询都是不变的,因此每次查询缓存只有一份,加缓存并不需要把入参作为key。只要每次进入查询入口方法的时候缓存,退出的时候删除就可以了。但这也限制了提取的方法的入参都要一样的。
当入口方法参数多的时候,可以提取成一个 dto,后面所有方法都以这一个 dto 为参数进行设计,这样方法更容易组装,因为入参都是同一个 dto,因此想调用什么方法都不用关心参数的问题。但这样函数的复用性就变得很差了,因为 dto 一般不同的查询是不同。
解决方法可以针对通用的查询细化参数,不使用 dto,只要参数是固定的,只缓存一份的规则仍然生效,但里面组合的函数也要改成一样的参数。实际使用中单个查询里面的复用是非常多的,不同查询的复用较少。
总计
函数式查询本质是通过函数替代变量,通过缓存和柯里化解决性能问题。虽然上面的代码经过精简不能用,但组件已经完成,有个项目已用上,效果还不错,把大量的 map 都去掉了,提升了可读性,定位问题的速度也更快了。
欢迎加入我的知识星球,全面提升技术能力。
👉 加入方式,“长按”或“扫描”下方二维码噢:
星球的内容包括:项目实战、面试招聘、源码解析、学习路线。
文章有帮助的话,在看,转发吧。
谢谢支持哟 (*^__^*)