传统查询逻辑的问题
在代码评审的过程中,发现有些查询逻辑可读性太差,但是一时又没找到好的方法。复杂的查询往往需要查询大量的信息。例如下面的例子,获取测验的正确率和参与率。还有测验每道题目分别的正确率和参与率。
老师可以在课堂 (
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 questions = questionMapper.selectByExamId(examId); List examQuestionIds = questions.stream().map(Question::getId).collect(Collectors.toList()); // 获取所有学生的答题情况 List studentQuestionAnswers = studentQuestionAnswerMapper.selectByExamQuestionIds(examQuestionIds); Map> questionMap = studentQuestionAnswers.stream().collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId)); // 获取所有学生, 为了获取学生数 List students = studentMapper.selectByClassroom(exam.getClassroomId()); ExamDto examDto = new ExamDto(); // 计算测验正确率 examDto.setCorrectRate(calculateCorrectCount(studentQuestionAnswers)); // 计算测验参与度 examDto.setSubmittedRate(calculateSubmittedRate(studentQuestionAnswers,students)); List questionDtos = questions.stream().map(question -> { QuestionDto questionDto = new QuestionDto(); // 计算题目正确率 List 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 questionAnswers, List students) { return 1.0 * questionAnswers.size() / students.size(); } private long calculateCorrectCount (List 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) ;
更优雅的编写方式
我认为更优雅的实现方式就是去掉临时变量。在 里面也有提到通过方法替代变量的形式,目标就是让逻辑更加直观,暂时不考虑性能问题。理想的实现方式如下:
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 getStudents (String examId) { return studentMapper.selectByClassroom(getExam(examId).getClassroomId()); } private Exam getExam (String examId) { return examMapper.selectById(examId); } private List 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> getStudentQuestionAnswerPerQuestion(String examId) { Map> studentQuestionAnswerMap = getStudentQuestionAnswers(examId).stream() .collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId)); return question -> studentQuestionAnswerMap.getOrDefault(question.getId(), Collections.emptyList()); } private List getExamQuestions (String examId) { return questionMapper.selectByExamId(examId); } }
去掉临时变量之后前面提到的2个问题都得以解决,不存在临时变量,需要的时候用get方法替代即可。通过函数组合的方式,没有了中间变量,方法的定义复用性更好。例如增加一个单独查询测验正确率的方法,可以直接调用
calculateCorrectRate(examId)
即可,不用再传一堆变量。
虽然方法多了很多,但每个方法都单一职责,实际上用这种方式注释都可以不用加了,因为每个方法定义都表明了方法的作用。因为方法会比较细,因此针对复杂的查询最好还是单独一个类。
如何解决性能问题
方法重复调用会带来性能问题,如何解决性能问题? 我们观察
calculateExamSubmittedRate
和
calculateExamCorrectRate
共同调用了
getStudentQuestionAnswers
方法,函数的入参其实都是一样的。又因为查询是没有副作用的,相同的参数必定返回相同的结果,因此可以缓存下来。具体实现可以加本地缓存,通过线程变量来把函数的值存下来。解决了性能问题。最后再讨论技术实现方案。
那如果参数会变怎么办,例如
getStudentQuestionAnswerPerQuestion
里面的 question。不先通过批量查询的方式就算加缓存也要 n 次查询。这时可以从函数式编程寻找灵感。
查询函数式
用函数替代变量,看到了函数式的影子。在函数式编程里面,函数有几个特点
引用透明:
函数的返回值只取决于输入值,也就是没有副作用。
柯里化:
将接收多个参数的函数转换成一系列嵌套的单个参数的函数。
函数组合和透明引用是加缓存的基础,如果没有透明引用,是加不了缓存的。
getStudentQuestionAnswerPerQuestion
其实是传了2 个参数,一个是 examId,一个是 question。能否把函数柯里化,改成
getStudentQuestionAnswerPerQuestion(examId)(question)
呢? 好处就是通过
getStudentQuestionAnswerPerQuestion(examId)
提前把所有 question 都查出来,再利用这个函数去筛选出单个 question 的值。
/** * 函数柯里化 */ private Function> getStudentQuestionAnswerPerQuestion(String examId) { Map> 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 getStudents (String examId) { return studentMapper.selectByClassroom(getExam(examId).getClassroomId()); } private Exam getExam (String examId) { return examMapper.selectById(examId); } private List getStudentQuestionAnswers (String examId) { return studentQuestionAnswerMapper.selectByExamQuestionIds(getExamQuestionIds(getExamQuestions(examId))); } private static List getExamQuestionIds (List questions) { return questions.stream().map(Question::getId).collect(Collectors.toList()); } private Function calculateQuestionSubmittedRate (String examId) { return question -> 1.0 * getStudentQuestionAnswerPerQuestion(examId).apply(question).size() / getStudents(examId).size(); } private Function calculateQuestionCorrectRate (String examId) { return question -> 1.0 * getCorrectCountPerQuestion(examId).apply(question) / getAnswerCount(examId).apply(question); } private Function getAnswerCount (String examId) { return question -> getStudentQuestionAnswerPerQuestion(examId).apply(question).size(); } private Function getCorrectCountPerQuestion (String examId) { return question -> getStudentQuestionAnswerPerQuestion(examId).apply(question).stream().filter(StudentQuestionAnswer::isCorrect).count(); } private Function> getStudentQuestionAnswerPerQuestion(String examId) { Map> studentQuestionAnswerMap = getStudentQuestionAnswers(examId).stream().collect(Collectors.groupingBy(StudentQuestionAnswer::getExamQuestionId)); return question -> studentQuestionAnswerMap.getOrDefault(question.getId(), Collections.emptyList()); } private List 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 { return studentMapper.selectByClassroom(getExam(examId).classroomId) } private fun getExam (examId: String) : Exam { return examMapper.selectById(examId) } private fun getStudentQuestionAnswers (examId: String) : List { 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 { val studentQuestionAnswerMap = getStudentQuestionAnswers(examId) .groupBy(StudentQuestionAnswer::getExamQuestionId) return { question: Question -> studentQuestionAnswerMap[question.id] ?: emptyList() } } private fun getExamQuestions (examId: String) : List { 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?)