OptaPlanner Document 翻译(二)Quickstart-Hello world Java Quick start

306 阅读3分钟

Quickstart 快速开始

总览

每个快速开始的样例都能帮助你快速熟悉OptaPlanner。选择一个最贴近你需求的案例:

  • Hello World Java

    • 使用OP构建一个简单的Java 应用,来优化你们学校老师和学生的时间表。
  • Quarkus Java(推荐)

    • 使用OP构建一个REST风格的应用,来优化你们学校老师和学生的时间表。
    • Quarkus 是一个Java生态中的快速开发平台。其初衷就是快速开发并部署到云平台。其同时支持本地编译。由于编译时间的优化,可以为OP提供良好的性能提升。
  • SpringBoot Java (译者推荐)

    • 使用OP构建一个REST风格的应用,来优化你们学校老师和学生的时间表。
    • SpringBoot是Java生态中的另外一个平台。

以上所有的三个Quickstart样例都是使用OP去优化学校的时间表。

Hello world Java Quick start

以下操作指南将引导你使用OP构建一个简单的Java 应用。

1.你将构建什么样的一个应用

你将构建一个命令行模式的应用,用来优化学校老师和学生的时间表。

...
INFO  Solving ended: time spent (5000), best score (0hard/9soft), ...
INFO
INFO  |            | Room A     | Room B     | Room C     |
INFO  |------------|------------|------------|------------|
INFO  | MON 08:30  | English    | Math       |            |
INFO  |            | I. Jones   | A. Turing  |            |
INFO  |            | 9th grade  | 10th grade |            |
INFO  |------------|------------|------------|------------|
INFO  | MON 09:30  | History    | Physics    |            |
INFO  |            | I. Jones   | M. Curie   |            |
INFO  |            | 9th grade  | 10th grade |            |
INFO  |------------|------------|------------|------------|
INFO  | MON 10:30  | History    | Physics    |            |
INFO  |            | I. Jones   | M. Curie   |            |
INFO  |            | 10th grade | 9th grade  |            |
INFO  |------------|------------|------------|------------|
...
INFO  |------------|------------|------------|------------|

你的应用需要自动将”课程“实例分配到”时间槽“和”教室“实例,同时遵守一系列的软约束和硬约束,例如:

  • 一个教室在同一时间只能有一种课程
  • 一个老师在同一时间只能教授一种课程
  • 一个学生只能在同一时间上一种课程
  • 老师倾向于在同一个教室教授所有课程
  • 老师倾向于连续授课,而不喜欢课程中有间隔
  • 学生不喜欢同一个主题下的连续授课

从数学的角度讲,学校排时间表是一个NP-hard问题。这意味着它很难进行扩展。对于一个复杂的问题,如果只是简单采用遍历所有可能组合的方法,即使是使用超级计算机也可能需要花费数百万年。幸运的是一个AI约束求解器,例如OP,拥有先进的算法可以在可接受的时间范围内给出一个近最优方案。

2.解决方案的源码

你可以跟着接下来章节的指引,一步步的创建应用。或者直接查看完成版的例子:

1.完成以下任务之一:

a.Clone Git上的仓库:

 ````
 $ git clone https://github.com/kiegroup/optaplanner-quickstarts
 ````

b.下载完整文档:www.optaplanner.org/download/do…

2.在hello-world目录下找到对应的方案

3.根据README文档的指导运行应用

3.前提依赖

完成本样例需要以下工具:

  • JDK 11+ 并且配置好对应的环境变量
  • Apache Maven 3.8.1+ 或者 Gradle4 +
  • 一个IDE,例如IntelliJ IDE,VSCode 或者 Eclipse

4.build文件和依赖

创建一个Maven或者Gradle的build文件,并添加以下依赖:

  • optaplanner-core(编译域)用于解决学校时间表问题。
  • optaplanner-test(测试域)用于对学校排期约束进行单元测试。
  • 一个logging日志实现,例如logback-classic(运行域)用于观察OP执行的动作。

如果你选择了Maven,你的pom.xml应包含如下内容:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
​
  <groupId>org.acme</groupId>
  <artifactId>optaplanner-hello-world-school-timetabling-quickstart</artifactId>
  <version>1.0-SNAPSHOT</version>
​
  <properties>
    <maven.compiler.release>11</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
​
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.optaplanner</groupId>
        <artifactId>optaplanner-bom</artifactId>
        <version>8.31.0.Final</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
      <dependency>
        <groupId>ch.qos.logback</groupId>
        <artifactId>logback-classic</artifactId>
        <version>1.2.3</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
​
  <dependencies>
    <dependency>
      <groupId>org.optaplanner</groupId>
      <artifactId>optaplanner-core</artifactId>
    </dependency>
    <dependency>
      <groupId>ch.qos.logback</groupId>
      <artifactId>logback-classic</artifactId>
      <scope>runtime</scope>
    </dependency>
​
    <!-- Testing -->
    <dependency>
      <groupId>org.optaplanner</groupId>
      <artifactId>optaplanner-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
​
  <build>
    <plugins>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.0.0</version>
        <configuration>
          <mainClass>org.acme.schooltimetabling.TimeTableApp</mainClass>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

另一方面,如果你用的是Gradle,你的build.gradle文件应包含如下内容:

plugins {
    id "java"
    id "application"
}
​
def optaplannerVersion = "8.31.0.Final"
def logbackVersion = "1.2.9"group = "org.acme"
version = "1.0-SNAPSHOT"
​
repositories {
    mavenCentral()
}
​
dependencies {
    implementation platform("org.optaplanner:optaplanner-bom:${optaplannerVersion}")
    implementation "org.optaplanner:optaplanner-core"
    testImplementation "org.optaplanner:optaplanner-test"
​
    runtimeOnly "ch.qos.logback:logback-classic:${logbackVersion}"
}
​
java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}
​
compileJava {
    options.encoding = "UTF-8"
    options.compilerArgs << "-parameters"
}
​
compileTestJava {
    options.encoding = "UTF-8"
}
​
application {
    mainClass = "org.acme.schooltimetabling.TimeTableApp"
}
​
test {
    // Log the test execution results.
    testLogging {
        events "passed", "skipped", "failed"
    }
}

5.域对象建模

你的目标是将每个课程分配到对应的时间区间和教室。你需要创建以下类:

5.1Timeslot时间槽/时隙

时间槽Timeslot类代表用来上课的一个时间区间,例如:周一的10:30 - 11:30 或者 周二的 13:30 - 14:30。为了简单起见,所有的时间槽都具有相同的时长,并且不会出现在午餐或者其他休息时间。

时间槽本身没有日期,因为高中的时间表每周都是重复的。所以没必要进行连续规划。

创建 src/main/java/org/acme/schooltimetabling/domain/Timeslot.java 类:

package org.acme.schooltimetabling.domain;
​
import java.time.DayOfWeek;
import java.time.LocalTime;
​
public class Timeslot {
​
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;
​
    public Timeslot() {
    }
​
    public Timeslot(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
        this.dayOfWeek = dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }
​
    public DayOfWeek getDayOfWeek() {
        return dayOfWeek;
    }
​
    public LocalTime getStartTime() {
        return startTime;
    }
​
    public LocalTime getEndTime() {
        return endTime;
    }
​
    @Override
    public String toString() {
        return dayOfWeek + " " + startTime;
    }
​
}

因为Timeslot在求解期间不会发生改变,所以一个Timeslot被称之为一个问题事实 Problem Fact。这样的类不需要OP特定注解。

注意toString()方法的输出比较短,这样方便阅读调试日志或者跟踪日志。

5.2Room 教室

Room类代表课程被教授的地方,例如A教室或B教室。为了简单起见,所有的教室都没有容量上限,并且可以安排所有的课程教学。

创建类 src/main/java/org/acme/schooltimetabling/domain/Room.java :

package org.acme.schooltimetabling.domain;
​
public class Room {
​
    private String name;
​
    public Room() {
    }
​
    public Room(String name) {
        this.name = name;
    }
​
    public String getName() {
        return name;
    }
​
    @Override
    public String toString() {
        return name;
    }
​
}

Room实例在求解期间不会改变,所以他也是一个问题事实Problem Fact

5.3 Lesson 课程

在授课期间,老师会向一组学生教授一个特定主题内容,例如:9年级的数学由图灵A老师,10年级的化学由B居里老师教授。如果同一个主题内容被同一个老师在每周都教授给同一组学生,系统中则会存在多个Lesson的实例,这些实例根据id进行区分。例如9年级每周有6次数学课程。

在求解的过程中,OP通过改变Lesson类中的 timeslot和room属性的方式将课程分配到一个时间槽和教室。OP会不断的改变Lesson的属性,所以这个Lesson就是一个规划实体Planning Entity

上面图中的大多数属性都包含输入数据,除过橘色部分:一个课程的 timeslot 和 room 属性在输入数据中是未赋值的,在数出数据中则是已赋值。OP是在求解的过程中改变的这两个属性。这些属性被称之为规划变量Planning variables。为了让OP识别到这些规划变量,需要在这些属性上添加@PlanningVariable 注解。他们的容器类Lesson需要添加一个@PlanningEngity 注解。

创建 src/main/java/org/acme/schooltimetabling/domain/Lesson.java 类:

package org.acme.schooltimetabling.domain;
​
import org.optaplanner.core.api.domain.entity.PlanningEntity;
import org.optaplanner.core.api.domain.lookup.PlanningId;
import org.optaplanner.core.api.domain.variable.PlanningVariable;
​
@PlanningEntity
public class Lesson {
​
    @PlanningId
    private Long id;
​
    private String subject;
    private String teacher;
    private String studentGroup;
​
    @PlanningVariable(valueRangeProviderRefs = "timeslotRange")
    private Timeslot timeslot;
    @PlanningVariable(valueRangeProviderRefs = "roomRange")
    private Room room;
​
    public Lesson() {
    }
​
    public Lesson(Long id, String subject, String teacher, String studentGroup) {
        this.id = id;
        this.subject = subject;
        this.teacher = teacher;
        this.studentGroup = studentGroup;
    }
​
    public Long getId() {
        return id;
    }
​
    public String getSubject() {
        return subject;
    }
​
    public String getTeacher() {
        return teacher;
    }
​
    public String getStudentGroup() {
        return studentGroup;
    }
​
    public Timeslot getTimeslot() {
        return timeslot;
    }
​
    public void setTimeslot(Timeslot timeslot) {
        this.timeslot = timeslot;
    }
​
    public Room getRoom() {
        return room;
    }
​
    public void setRoom(Room room) {
        this.room = room;
    }
​
    @Override
    public String toString() {
        return subject + "(" + id + ")";
    }
​
}

Lesson类有@PlanningEntity 注解,这样一来OP就知道这个类会在求解过程中发生变化,因为它包含了一到多个规划变量。

timeslot属性有@PlanningVariable注解,OP就知道这个属性的值会发生变化。为了找到这个属性关联的潜在timeslot实例,OP使用valueRangeProviderRefs属性关联到一个值域产生器value range provider,这个产生器会提供一个List 列表供timeslot从中选择。

room属性基于同样的原因,也有一个@PlanningVariable注解。

第一次指定随意的一个约束求解用例中的@PlanningVariable属性是具有挑战性的。请阅读 对象域建模指导 www.optaplanner.org/docs/optapl…以避免常见的问题/陷阱。

6.确定约束,并计算分值

分值代表了一个规划方案的质量,分值越高质量越好。OP寻求的是分值最高的方案,这个方案有可能是最优方案。

因为当前的问题同时存在硬约束和软约束,所以使用HardSoftScore类来代表分值:

  • 硬约束必须不能被打破:例如一个教室同一时间只能正在教授一种课程。
  • 软约束尽可能的不被打破:同一个老师倾向于在同一个教室授课。

硬约束之间可以进行加权,软约束之间同样可以进行加权;硬约束总是大于软约束,而忽略其各自的权重。

为了计算分值,你可以实现一个EasyScoreCalculator类:

public class TimeTableEasyScoreCalculator implements EasyScoreCalculator<TimeTable, HardSoftScore> {
​
    @Override
    public HardSoftScore calculateScore(TimeTable timeTable) {
        List<Lesson> lessonList = timeTable.getLessonList();
        int hardScore = 0;
        for (Lesson a : lessonList) {
            for (Lesson b : lessonList) {
                if (a.getTimeslot() != null && a.getTimeslot().equals(b.getTimeslot())
                        && a.getId() < b.getId()) {
                    //一个教室统一时刻只能正在教授一种课程
                    if (a.getRoom() != null && a.getRoom().equals(b.getRoom())) {
                        hardScore--;
                    }
                    //一个老师同一时刻只能正在教授一种课程
                    if (a.getTeacher().equals(b.getTeacher())) {
                        hardScore--;
                    }
                    //一个学生同一时刻只能正在学习一种课程
                    if (a.getStudentGroup().equals(b.getStudentGroup())) {
                        hardScore--;
                    }
                }
            }
        }
        int softScore = 0;
        // Soft constraints are only implemented in the optaplanner-quickstarts code
        return HardSoftScore.of(hardScore, softScore);
    }
}

很不幸的是以上的代码扩展性不好,这个是非增量式的:每次一个课程被分配到一个时间槽和教室的时候,所有课程的分值都需要重新评估。

改进方法,创建一个 src/main/java/org/acme/schooltimetabling/solver/TimeTableConstraintProvider.java 类来实现增量计算。这就需要使用OP提供的基于Java Stream 和SQL 的ConstraintStream API:

package org.acme.schooltimetabling.solver;
​
import org.acme.schooltimetabling.domain.Lesson;
import org.optaplanner.core.api.score.buildin.hardsoft.HardSoftScore;
import org.optaplanner.core.api.score.stream.Constraint;
import org.optaplanner.core.api.score.stream.ConstraintFactory;
import org.optaplanner.core.api.score.stream.ConstraintProvider;
import org.optaplanner.core.api.score.stream.Joiners;
​
public class TimeTableConstraintProvider implements ConstraintProvider {
​
    @Override
    public Constraint[] defineConstraints(ConstraintFactory constraintFactory) {
        return new Constraint[] {
                // 硬约束
                roomConflict(constraintFactory),
                teacherConflict(constraintFactory),
                studentGroupConflict(constraintFactory),
                // Soft constraints are only implemented in the optaplanner-quickstarts code
        };
    }
​
    private Constraint roomConflict(ConstraintFactory constraintFactory) {
        // 一个教室同一时刻只能安排一种课程
​
        // 选择一个课程....
        return constraintFactory
                .forEach(Lesson.class)
                // ... and pair it with another lesson ...
                .join(Lesson.class,
                        // ... 同一个时间槽 ...
                        Joiners.equal(Lesson::getTimeslot),
                        // ... 同一个教室 ...
                        Joiners.equal(Lesson::getRoom),
                        // ... and the pair is unique (different id, no reverse pairs) ...
                        Joiners.lessThan(Lesson::getId))
                // ... then penalize each pair with a hard weight.
                .penalize(HardSoftScore.ONE_HARD)
                .asConstraint("Room conflict");
    }
​
    private Constraint teacherConflict(ConstraintFactory constraintFactory) {
        // 一个老师同一时刻只能教授一种课程
        return constraintFactory.forEach(Lesson.class)
                .join(Lesson.class,
                        Joiners.equal(Lesson::getTimeslot),
                        Joiners.equal(Lesson::getTeacher),
                        Joiners.lessThan(Lesson::getId))
                .penalize(HardSoftScore.ONE_HARD)
                .asConstraint("Teacher conflict");
    }
​
    private Constraint studentGroupConflict(ConstraintFactory constraintFactory) {
        // 一组学生同一时刻只能学习一种课程
        return constraintFactory.forEach(Lesson.class)
                .join(Lesson.class,
                        Joiners.equal(Lesson::getTimeslot),
                        Joiners.equal(Lesson::getStudentGroup),
                        Joiners.lessThan(Lesson::getId))
                .penalize(HardSoftScore.ONE_HARD)
                .asConstraint("Student group conflict");
    }
}

新的ConstraintProvider在扩展性方面的复杂度是O(n) 优于EasyScoreCalculator的 O(n²)。

7.获取规划解决方案中的域对象

Timetable将所有的Timeslot、Room、Lesson实例都打包到了同一个数据集中。进一步的,它包含了所有的课程并且每个课程都具有不同的规划变量状态,所以它就代表了一个规划问题的解决方案,并且我们对他有一个评分:

  • 如果有课程仍旧未被分配,则当前的方案是一个未初始化的方案,例如:一个得分情况为-4init/0 hard/0 soft的方案。
  • 如果方案打破了硬约束,则是一个不可行的方案,例如:一个得分为-2 hard/-3 soft的方案。
  • 如果方案遵守了硬约束,则是一个可行方案,例如:一个得分为 0 hard/-7 soft的方案。

创建 src/main/java/org/acme/schooltimetabling/domain/TimeTable.java 类:

package org.acme.schooltimetabling.domain;
​
import java.util.List;
​
import org.optaplanner.core.api.domain.solution.PlanningEntityCollectionProperty;
import org.optaplanner.core.api.domain.solution.PlanningScore;
import org.optaplanner.core.api.domain.solution.PlanningSolution;
import org.optaplanner.core.api.domain.solution.ProblemFactCollectionProperty;
import org.optaplanner.core.api.domain.valuerange.ValueRangeProvider;
import org.optaplanner.core.api.score.buildin.hardsoft.HardSoftScore;
​
@PlanningSolution
public class TimeTable {
​
    @ValueRangeProvider(id = "timeslotRange")
    @ProblemFactCollectionProperty
    private List<Timeslot> timeslotList;
    
    @ValueRangeProvider(id = "roomRange")
    @ProblemFactCollectionProperty
    private List<Room> roomList;
    
    @PlanningEntityCollectionProperty
    private List<Lesson> lessonList;
​
    @PlanningScore
    private HardSoftScore score;
​
    public TimeTable() {
    }
​
    public TimeTable(List<Timeslot> timeslotList, List<Room> roomList, List<Lesson> lessonList) {
        this.timeslotList = timeslotList;
        this.roomList = roomList;
        this.lessonList = lessonList;
    }
​
    public List<Timeslot> getTimeslotList() {
        return timeslotList;
    }
​
    public List<Room> getRoomList() {
        return roomList;
    }
​
    public List<Lesson> getLessonList() {
        return lessonList;
    }
​
    public HardSoftScore getScore() {
        return score;
    }
}

类Timetable上有@PlanningSolution注解,所以OP就知道当前类包含所有的输入及输出数据。

当前类是规划问题的输入数据:

  • 一个timeslotList 属性,包含所有的时间槽。

    • 这是一个问题事实,在求解期间值不改变。
  • 一个roomList 属性,包含所有教室。

    • 这是一个问题事实,在求解期间值不改变。
  • 一个lessonList属性,包含所有课程。

    • 这是一个问题实体,在求解期间会发生变化。

    • 对于每个课程:

      • timeslot属性和room属性通常都是为null,也就是未赋值,它们是规划变量
      • 其他属性例如 teacher、studentGroup已经赋值,它们是问题属性。

当前的Timeslot类也是解决方案的输出数据:

  • 一个lessonList 属性,在求解之后其中的每个lesson实例的timeslot和room字段都已非空。
  • 一个score属性,用来代表当前输出解决方案的质量,例如 0 hard/-5 soft。

7.1 值域提供器 value range providers

timeslotList是一个值域提供器,持有所有的timeslot实例。OP可以在设置Lesson实例中的timeslot属性时从中选择。

timeslotList属性上有@ValueRangeProvider注解,通过这个注解中的id值和Lesson类中的@PlanningVariable注解中的valueRangeProviderRefs 值进行对应,就可以实现@PlanningVarialble和@ValueRangeProvider的关联。

按照同样的逻辑,roomList属性上也有@ValueRangeProvider注解。

7.2问题事实和问题实体属性

更进一步,OP需要通过你提供的TimeTableConstraintProvider来确定当前它可以对哪个Lesson实例进行修改,以及如何检索分值计算时用到的timeslot和room实例。

timeslotList和roomList属性上有@ProblemFactCollectionProperty注解,所以TimeTableConstraintProvider可以从这些list实例中获取需要的timeslot和room实例。

lessonList属性上有@PlanningEntityCollectionProperty,所以OP知道应该修改什么,以及TimeTableConstraintProvider可以从这个list中获取要修改的实例。

8.创建应用

现在我们已经准备好了,可以将以上的所有内容组合起来创建一个Java应用。main()方法将执行以下任务:

1.创建一个SolverFactory,来为每个要处理的数据集生成对应的Solver。

2.加载数据集

3.调用Solver.solve()方法进行问题求解。

4.将得到的解决方案进行可视化展示。

通常,一个应用会建立一个SolverFactory来为每个规划求解的数据集生成对应的Solver实例。SolverFactory是线程安全的,但Solver不是。在当前场景下我们只有一个规划问题数据集,所以只有一个Solver实例。

创建 src/main/java/org/acme/schooltimetabling/TimeTableApp.java 类:

package org.acme.schooltimetabling;
​
import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
​
import org.acme.schooltimetabling.domain.Lesson;
import org.acme.schooltimetabling.domain.Room;
import org.acme.schooltimetabling.domain.TimeTable;
import org.acme.schooltimetabling.domain.Timeslot;
import org.acme.schooltimetabling.solver.TimeTableConstraintProvider;
import org.optaplanner.core.api.solver.Solver;
import org.optaplanner.core.api.solver.SolverFactory;
import org.optaplanner.core.config.solver.SolverConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
​
public class TimeTableApp {
​
    private static final Logger LOGGER = LoggerFactory.getLogger(TimeTableApp.class);
​
    public static void main(String[] args) {
        SolverFactory<TimeTable> solverFactory = SolverFactory.create(new SolverConfig()
                .withSolutionClass(TimeTable.class)
                .withEntityClasses(Lesson.class)
                .withConstraintProviderClass(TimeTableConstraintProvider.class)
                // 当前数据集比较小,Solver只运行了5秒
                // 建议至少运行5分钟 5m
                .withTerminationSpentLimit(Duration.ofSeconds(10)));
​
        // 加载问题
        TimeTable problem = generateDemoData();
​
        // 问题求解
        Solver<TimeTable> solver = solverFactory.buildSolver();
        TimeTable solution = solver.solve(problem);
​
        // 结果方案的可视化
        printTimetable(solution);
    }
​
    public static TimeTable generateDemoData() {
        List<Timeslot> timeslotList = new ArrayList<>(10);
        timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)));
        timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(9, 30), LocalTime.of(10, 30)));
        timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(10, 30), LocalTime.of(11, 30)));
        timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(13, 30), LocalTime.of(14, 30)));
        timeslotList.add(new Timeslot(DayOfWeek.MONDAY, LocalTime.of(14, 30), LocalTime.of(15, 30)));
​
        timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(8, 30), LocalTime.of(9, 30)));
        timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(9, 30), LocalTime.of(10, 30)));
        timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(10, 30), LocalTime.of(11, 30)));
        timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(13, 30), LocalTime.of(14, 30)));
        timeslotList.add(new Timeslot(DayOfWeek.TUESDAY, LocalTime.of(14, 30), LocalTime.of(15, 30)));
​
        List<Room> roomList = new ArrayList<>(3);
        roomList.add(new Room("Room A"));
        roomList.add(new Room("Room B"));
        roomList.add(new Room("Room C"));
​
        List<Lesson> lessonList = new ArrayList<>();
        long id = 0;
        lessonList.add(new Lesson(id++, "Math", "A. Turing", "9th grade"));
        lessonList.add(new Lesson(id++, "Math", "A. Turing", "9th grade"));
        lessonList.add(new Lesson(id++, "Physics", "M. Curie", "9th grade"));
        lessonList.add(new Lesson(id++, "Chemistry", "M. Curie", "9th grade"));
        lessonList.add(new Lesson(id++, "Biology", "C. Darwin", "9th grade"));
        lessonList.add(new Lesson(id++, "History", "I. Jones", "9th grade"));
        lessonList.add(new Lesson(id++, "English", "I. Jones", "9th grade"));
        lessonList.add(new Lesson(id++, "English", "I. Jones", "9th grade"));
        lessonList.add(new Lesson(id++, "Spanish", "P. Cruz", "9th grade"));
        lessonList.add(new Lesson(id++, "Spanish", "P. Cruz", "9th grade"));
​
        lessonList.add(new Lesson(id++, "Math", "A. Turing", "10th grade"));
        lessonList.add(new Lesson(id++, "Math", "A. Turing", "10th grade"));
        lessonList.add(new Lesson(id++, "Math", "A. Turing", "10th grade"));
        lessonList.add(new Lesson(id++, "Physics", "M. Curie", "10th grade"));
        lessonList.add(new Lesson(id++, "Chemistry", "M. Curie", "10th grade"));
        lessonList.add(new Lesson(id++, "French", "M. Curie", "10th grade"));
        lessonList.add(new Lesson(id++, "Geography", "C. Darwin", "10th grade"));
        lessonList.add(new Lesson(id++, "History", "I. Jones", "10th grade"));
        lessonList.add(new Lesson(id++, "English", "P. Cruz", "10th grade"));
        lessonList.add(new Lesson(id++, "Spanish", "P. Cruz", "10th grade"));
​
        return new TimeTable(timeslotList, roomList, lessonList);
    }
​
    private static void printTimetable(TimeTable timeTable) {
        LOGGER.info("");
        List<Room> roomList = timeTable.getRoomList();
        List<Lesson> lessonList = timeTable.getLessonList();
        Map<Timeslot, Map<Room, List<Lesson>>> lessonMap = lessonList.stream()
                .filter(lesson -> lesson.getTimeslot() != null && lesson.getRoom() != null)
                .collect(Collectors.groupingBy(Lesson::getTimeslot, Collectors.groupingBy(Lesson::getRoom)));
        LOGGER.info("|            | " + roomList.stream()
                .map(room -> String.format("%-10s", room.getName())).collect(Collectors.joining(" | ")) + " |");
        LOGGER.info("|" + "------------|".repeat(roomList.size() + 1));
        for (Timeslot timeslot : timeTable.getTimeslotList()) {
            List<List<Lesson>> cellList = roomList.stream()
                    .map(room -> {
                        Map<Room, List<Lesson>> byRoomMap = lessonMap.get(timeslot);
                        if (byRoomMap == null) {
                            return Collections.<Lesson>emptyList();
                        }
                        List<Lesson> cellLessonList = byRoomMap.get(room);
                        if (cellLessonList == null) {
                            return Collections.<Lesson>emptyList();
                        }
                        return cellLessonList;
                    })
                    .collect(Collectors.toList());
​
            LOGGER.info("| " + String.format("%-10s",
                    timeslot.getDayOfWeek().toString().substring(0, 3) + " " + timeslot.getStartTime()) + " | "
                    + cellList.stream().map(cellLessonList -> String.format("%-10s",
                            cellLessonList.stream().map(Lesson::getSubject).collect(Collectors.joining(", "))))
                            .collect(Collectors.joining(" | "))
                    + " |");
            LOGGER.info("|            | "
                    + cellList.stream().map(cellLessonList -> String.format("%-10s",
                            cellLessonList.stream().map(Lesson::getTeacher).collect(Collectors.joining(", "))))
                            .collect(Collectors.joining(" | "))
                    + " |");
            LOGGER.info("|            | "
                    + cellList.stream().map(cellLessonList -> String.format("%-10s",
                            cellLessonList.stream().map(Lesson::getStudentGroup).collect(Collectors.joining(", "))))
                            .collect(Collectors.joining(" | "))
                    + " |");
            LOGGER.info("|" + "------------|".repeat(roomList.size() + 1));
        }
        List<Lesson> unassignedLessons = lessonList.stream()
                .filter(lesson -> lesson.getTimeslot() == null || lesson.getRoom() == null)
                .collect(Collectors.toList());
        if (!unassignedLessons.isEmpty()) {
            LOGGER.info("");
            LOGGER.info("Unassigned lessons");
            for (Lesson lesson : unassignedLessons) {
                LOGGER.info("  " + lesson.getSubject() + " - " + lesson.getTeacher() + " - " + lesson.getStudentGroup());
            }
        }
    }
​
}

main()方法中首先创建了SolverFactory:

SolverFactory<TimeTable> solverFactory = SolverFactory.create(new SolverConfig()
        .withSolutionClass(TimeTable.class)
        .withEntityClasses(Lesson.class)
        .withConstraintProviderClass(TimeTableConstraintProvider.class)
        // 当前数据集比较小,Solver只运行了5秒
        // 建议至少运行5分钟 5m
        .withTerminationSpentLimit(Duration.ofSeconds(5)));

以上代码将我们之前创建的@PlanningSolution类、@PlanningEntigy类、ConstraintProvider类进行了注册。

如果没有运行时间上限设置或者terminationEarly(),我们的求解器可能会一直运行下去,为了避免这种情况出现,我们设置了求解器的最大求解时间为5秒。

5秒后,main()方法将加载规划问题,求解并打印方案结果:

        // 加载问题
        TimeTable problem = generateDemoData();
​
        // 问题求解
        Solver<TimeTable> solver = solverFactory.buildSolver();
        TimeTable solution = solver.solve(problem);
​
        // 结果方案的可视化
        printTimetable(solution); 

solve()方法不会立即返回结果,而是在运行5秒后返回再返回此时得到的最好结果。OP在给定的时间内会返回已经找到的最好的方案。但是由于NP-hard问题本身,当前获取的最好方案可能不是最优方案,尤其是针对大数据集。通过增加限定时长去尽可能的找到更好的方案。

generateDemoData()方法用于生成当前学校课程表的数据。

printTimetable()方法将会打印课程表到控制台,这样就可以很多容易的通过可视化的方式判断当前的排程的好坏。

8.1配置日志

为了在终端看到输出,需要正确的配置日志。

创建 src/main/resource/logback.xml 文件:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
​
  <appender name="consoleAppender" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{HH:mm:ss.SSS} [%-12.12t] %-5p %m%n</pattern>
    </encoder>
  </appender>
​
  <logger name="org.optaplanner" level="info"/>
​
  <root level="info">
    <appender-ref ref="consoleAppender" />
  </root>
</configuration>

9.运行程序

9.1在IDE中运行程序

将TimeTableApp类作为主类运行:

...
INFO  |            | Room A     | Room B     | Room C     |
INFO  |------------|------------|------------|------------|
INFO  | MON 08:30  | English    | Math       |            |
INFO  |            | I. Jones   | A. Turing  |            |
INFO  |            | 9th grade  | 10th grade |            |
INFO  |------------|------------|------------|------------|
INFO  | MON 09:30  | History    | Physics    |            |
INFO  |            | I. Jones   | M. Curie   |            |
INFO  |            | 9th grade  | 10th grade |            |
...

检查控制台的输出。是否满足所有的硬约束?如果你将TimeTableConstraintProvider中的roomConflict 注释掉会发生什么?

info级别的log会展示OP在这5秒内都做了哪些事情:

... Solving started: time spent (33), best score (-8init/0hard/0soft), environment mode (REPRODUCIBLE), random (JDK with seed 0).
... Construction Heuristic phase (0) ended: time spent (73), best score (0hard/0soft), score calculation speed (459/sec), step total (4).
... Local Search phase (1) ended: time spent (5000), best score (0hard/0soft), score calculation speed (28949/sec), step total (28398).
... Solving ended: time spent (5000), best score (0hard/0soft), score calculation speed (28524/sec), phase total (2), environment mode (REPRODUCIBLE).

9.2 对应用进行测试

一个好的程序应包含覆盖测试。

9.2.1 约束测试

在单元测试中使用ConstraintVerifier对每个约束进行单独测试。这个会独立隔离式的测试每个约束的边界情况,在覆盖测试的情况下可以降低新增一个约束时的维护量。

创建 src/test/java/org/acme/schooltimetabling/solver/TimeTableConstraintProviderTest.java 类:

package org.acme.schooltimetabling.solver;
​
import java.time.DayOfWeek;
import java.time.LocalTime;
​
import org.acme.schooltimetabling.domain.Lesson;
import org.acme.schooltimetabling.domain.Room;
import org.acme.schooltimetabling.domain.TimeTable;
import org.acme.schooltimetabling.domain.Timeslot;
import org.junit.jupiter.api.Test;
import org.optaplanner.test.api.score.stream.ConstraintVerifier;
​
class TimeTableConstraintProviderTest {
​
    private static final Room ROOM1 = new Room("Room1");
    private static final Timeslot TIMESLOT1 = new Timeslot(DayOfWeek.MONDAY, LocalTime.NOON);
    private static final Timeslot TIMESLOT2 = new Timeslot(DayOfWeek.TUESDAY, LocalTime.NOON);
​
    ConstraintVerifier<TimeTableConstraintProvider, TimeTable> constraintVerifier = ConstraintVerifier.build(
            new TimeTableConstraintProvider(), TimeTable.class, Lesson.class);
​
    @Test
    void roomConflict() {
        Lesson firstLesson = new Lesson(1, "Subject1", "Teacher1", "Group1", TIMESLOT1, ROOM1);
        Lesson conflictingLesson = new Lesson(2, "Subject2", "Teacher2", "Group2", TIMESLOT1, ROOM1);
        Lesson nonConflictingLesson = new Lesson(3, "Subject3", "Teacher3", "Group3", TIMESLOT2, ROOM1);
        constraintVerifier.verifyThat(TimeTableConstraintProvider::roomConflict)
                .given(firstLesson, conflictingLesson, nonConflictingLesson)
                .penalizesBy(1);
    }
​
}

以上测试验证了约束 TimeTableConstraintProvider::roomConflict 会在三个课程在同一个教室教授,并且两个课程的时间冲突的情况下,判罚出匹配值为 1 的权重。因此,违反权重为10的约束将对整体评分的影响为 -10hard。

需要注意ConstraintVerifier在测试的过程中是如何忽略约束的权重的:即使是这些约束的权重被硬编码在ConstraintProvider中,约束的权重在进入生产环境之前会经常性的变动。在这种情况下,约束权重的调整不影响单元测试。

更多内容请查看 Testing Constraint Streams www.optaplanner.org/docs/optapl…

9.3 日志

当向ConstraintProvider添加约束时,请留意info日志中分值计算的速度,在求解了同样长的时间后评估下整体表现的影响:

... Solving ended: ..., score calculation speed (29455/sec), ...

为了理解OP是内部是如何求解规划问题,可以修改日志级别配置logback.xml文件:

  <logger name="org.optaplanner" level="debug"/>

使用debug模式可以展示OP的每一步的情况:

... Solving started: time spent (67), best score (-20init/0hard/0soft), environment mode (REPRODUCIBLE), random (JDK with seed 0).
...     CH step (0), time spent (128), score (-18init/0hard/0soft), selected move count (15), picked move ([Math(101) {null -> Room A}, Math(101) {null -> MONDAY 08:30}]).
...     CH step (1), time spent (145), score (-16init/0hard/0soft), selected move count (15), picked move ([Physics(102) {null -> Room A}, Physics(102) {null -> MONDAY 09:30}]).
...

使用trace 日志级别可以展示每一步及每一步的动作。

9.4 创建独立运行的应用程序

为了在IDE之外运行应用程序,你需要对你的编译工具的配置进行一些修改。

9.4.1Maven中的可执行JAR

在Maven中向pom.xml中添加如下内容:

  ...
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-assembly-plugin</artifactId>
        <version>${version.assembly.plugin}</version>
        <configuration>
          <finalName>hello-world-run</finalName>
          <appendAssemblyId>false</appendAssemblyId>
          <descriptors>
            <descriptor>src/assembly/jar-with-dependencies-and-services.xml</descriptor>
          </descriptors>
          <archive>
            <manifestEntries>
              <Main-Class>org.acme.schooltimetabling.TimeTableApp</Main-Class>
              <Multi-Release>true</Multi-Release>
            </manifestEntries>
          </archive>
        </configuration>
        <executions>
          <execution>
            <id>make-assembly</id>
            <phase>package</phase>
            <goals>
              <goal>single</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
      ...
    </plugins>
    ...
  </build>
  ...

同时,在src/assembly目录下创建一个名为 jar-with-dependencies-and-services.xml 的文件,内容如下:

  <assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.0"
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.0 http://maven.apache.org/xsd/assembly-2.1.0.xsd">
    <id>jar-with-dependencies-and-services</id>
    <formats>
      <format>jar</format>
    </formats>
    <containerDescriptorHandlers>
      <containerDescriptorHandler>
        <handlerName>metaInf-services</handlerName>
      </containerDescriptorHandler>
    </containerDescriptorHandlers>
    <includeBaseDirectory>false</includeBaseDirectory>
    <dependencySets>
      <dependencySet>
        <outputDirectory>/</outputDirectory>
        <useProjectArtifact>true</useProjectArtifact>
        <unpack>true</unpack>
        <scope>runtime</scope>
      </dependencySet>
    </dependencySets>
  </assembly>

以上配置可以启用Maven Assembly Plugin插件,并且告诉插件做以下事情:

  • 将当前项目所有依赖对应的classes和资源打包到一个新的JAR中。

    • 如果任意一个依赖使用了Java SPI,插件将正确的绑定所有服务描述。
    • 如果任意一个依赖是多版本发布的JAR,插件也将正确的处理。
  • 将JAR的main class设置为 org.acme.schooltimetabling.TimeTableApp。

  • 在你的工程的构建目录下(一般是target/)生成hello-world-run.jar。

这个JAR包跟其他可运行的JAR包一样运行:

$ mvn clean install
...
$ java -jar target/hello-world-run.jar
9.4.2 在Gradle中的可运行应用

在Gradle中,在你的build.gradle文件中添加如下内容:

application {
    mainClass = "org.acme.schooltimetabling.TimeTableApp"
}

在构建完成项目后,你可以在build/distributions/ 目录下找到一个具有可执行程序的文件。

10.总结

恭喜!你已经使用OP开发了一个Java应用!

如果你遇到任何问题,可以查看下 the quickstart source code github.com/kiegroup/op…

阅读下一个指导内容,来构建一个具有REST 服务并且整合了数据的网页应用。