人话模式
打从去年开始做2.0系统的时候就考虑说要引入“人话模式”。起因是当初1.0上线的时候,有业务同学提出了一个从未设想过的问题。“你们不能用AND/OR/NOT来表示逻辑,要以我们的习惯来”。虽然我是觉得逻辑操作符还没有超出“人话”的范畴,但总之是埋下了个种子。于是最近总算稍微开始做一些了。
每次写代码遇到一些瓶颈的时候,就开始折腾些有的没的。折腾TailScale也差不多是因为这个。这次的切入点是年初写的个调度器。因为实在不想用xxl-job,干脆自己撸了一套。既然是调度器,那首先想到的就是CronTab。这不就好办了,全部用Cron的表达式就好了。但是这东西有个很麻烦的点,就是不太能一眼看明白到底会在什么时间点触发。所以第一步,先给Cron的“人话模式”搞出来。
一开始想着既然CronTab用的这么广泛,这种工具难道不是遍地都是?简单搜了一下还真不是。CronUtils包虽然提供了CronDescriptor这种工具,但是实际用过之后发现,与其去看它输出的“人话”,还不如直接看表达式。当然了,对于市面上的LLM来说,这事情都很简单。但是这么简单的,直接通过规则就能达成的事情,再部署个大(小)模型,似乎不是嗯划得来。当然了,call API也行。LLM卷到这个地步,大家都是白菜价。当然,作为一个靠写代码吃饭的人,还是姑且想自己来。
下午的时候问了下Gemini 2.5 pro关于写个CronTranslator的事情,Gemini很痛快的给了300多行代码。事实上我都没仔细看,先贴过来试试。居然能用。当然,他的那个代码风格我是不老喜欢的。加上只处理了基础,L,W,#的处理事实上都被略过了。倒是模型很贴心的在注释里写明了如果要额外处理这些附加指令,把代码加在这儿就行。虽然Google很大方的提供了预览版模型1M token(每天?每小时?)的使用,直接让模型去给补全肯定是够够的。但是来都来了,当然是自己动手丰衣足食。
因为要兼容Spring Scheduler的Cron表达式,所以一开始就用的 “秒 分 时 月日期 月 周日期” 的6个field的版本。语法部分参考了阿里云关于Cron表达式的文档,和Claude的解释。总之是花了一个半个下午和一个晚上,400来行代码。得到一个我自己已经觉得够用的版本。(最近降压药都吃翻倍剂量了,还是有点儿控制不住的势头,脑袋懵懵的)。先看看效果。
cron | text |
---|---|
* * * * * * | 每秒 |
1,2,20-23 * 20 * * * | 每天的第20时的每分钟的第1秒,第2秒,从第20秒到第23秒 |
* * * LW * * | 每月的最后一个工作日的每秒 |
* * * * 1-10/2 6#2 | 从一月到十月的每第2个月的第2个星期六的每秒 |
* * * L 1-10/2 ? | 从一月到十月的每第2个月的最后一天的每秒 |
* 1-20/3 * LW 1-10 ? | 从一月到十月的最后一个工作日的每小时的从第1分到第20分的每第3分的每秒 |
* 1,2,3/2 * * * * | 每小时的第1分,第2分,从第3分开始的每第2分的每秒 |
0 1-4 * * * * | 每小时的从第1分到第4分整 |
* * * L-3,LW,L * * | 每月的离月底倒数第3天,最后一个工作日,最后一天的每秒 |
* * * ? * L | 每月的最后一个星期日的每秒 |
* * * ? * 2L,L | 每月的最后一个星期二,最后一个星期日的每秒 |
0 0 * ? * L | 每月的最后一个星期日的每个整点 |
0 * * * * MON-WED | 每月的从星期一到星期三的每小时的每个整分 |
* 0 * * APR-OCT L | 从四月到十月的最后一个星期日的每小时的第一分钟的每秒 |
1,23,45 0 * * * L | 每月的最后一个星期日的每小时的第一分钟的第1秒,第23秒,第45秒 |
0 0 8,10 * * * | 每天的第8时,第10时整 |
0 0 8 3W,15W 2-10 ? | 从二月到十月的离3号最近的工作日,离15号最近的工作日的第8时整 |
虽然不说是涵盖了所有,基本够用了。等明儿有空了让AI给列一些edge case来处理看看。
懒得贴GitHub了,直接放在这儿好了。嗷,倒是用到了工具类里的一些东西。Pair
, concatList
之类的玩意儿随便一个包都有,贴个foldLeft
吧。Java的集合类没有原生支持foldLeft
真是遗憾。
public static <A, B> B foldLeft(
B initialValue,
Collection<A> in,
BiFunction<B, A, B> func
) {
if (in.isEmpty()) {
return initialValue;
} else {
B value = initialValue;
for (A a : in) {
value = func.apply(value, a);
}
return value;
}
}
import lombok.With;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@With
public record CronWrapper(
String second,
String minute,
String hour,
String dayOfMonth,
String month,
String dayOfWeek
) {
public CronWrapper {
Objects.requireNonNull(second);
Objects.requireNonNull(minute);
Objects.requireNonNull(hour);
Objects.requireNonNull(dayOfMonth);
Objects.requireNonNull(month);
Objects.requireNonNull(dayOfWeek);
}
public static CronWrapper empty() {
return new CronWrapper("*", "*", "*", "*", "*", "*");
}
public static CronWrapper fromCron(String cron) {
var withIndex = CollectionUtil.zipWithIndex(
Arrays.stream(
normalize(cron.trim())
.split("\\s+"))
.toList(), 1);
if (withIndex.size() != 6) {
throw new IllegalArgumentException("Invalid cron: " + cron);
} else {
var asMap = CollectionUtil.toMap(withIndex.stream().map(Pair::swap).toList());
var second = asMap.getOrDefault(1, "*");
var minute = asMap.getOrDefault(2, "*");
var hour = asMap.getOrDefault(3, "*");
var dayOfMonth = asMap.getOrDefault(4, "*");
var month = asMap.getOrDefault(5, "*");
var dayOfWeek = asMap.getOrDefault(6, "*");
return empty()
.withSecond(second)
.withMinute(minute)
.withHour(hour)
.withDayOfMonth(dayOfMonth)
.withMonth(month)
.withDayOfWeek(dayOfWeek);
}
}
private static List<Pair<String, Integer>> monthMap() {
return CollectionUtil.list(
Pair.of("JAN", 1),
Pair.of("FEB", 2),
Pair.of("MAR", 3),
Pair.of("APR", 4),
Pair.of("MAY", 5),
Pair.of("JUN", 6),
Pair.of("JUL", 7),
Pair.of("AUG", 8),
Pair.of("SEP", 9),
Pair.of("OCT", 10),
Pair.of("NOV", 11),
Pair.of("DEC", 12)
);
}
private static List<Pair<String, Integer>> weekMap() {
return CollectionUtil.list(
Pair.of("MON", 1),
Pair.of("TUE", 2),
Pair.of("WED", 3),
Pair.of("THU", 4),
Pair.of("FRI", 5),
Pair.of("SAT", 6),
Pair.of("SUN", 0)
);
}
private static String week(String value) {
return switch (value) {
case "1" -> "星期一";
case "2" -> "星期二";
case "3" -> "星期三";
case "4" -> "星期四";
case "5" -> "星期五";
case "6" -> "星期六";
default -> "星期日";
};
}
private static String month(String value) {
return switch (value) {
case "1" -> "一月";
case "2" -> "二月";
case "3" -> "三月";
case "4" -> "四月";
case "5" -> "五月";
case "6" -> "六月";
case "7" -> "七月";
case "8" -> "八月";
case "9" -> "九月";
case "10" -> "十月";
case "11" -> "十一月";
case "12" -> "十二月";
default -> "";
};
}
private static String asIs(String value) {
return value;
}
private static String nSecond(String value) {
return "第" + value + "秒";
}
private static String nMinute(String value) {
return "第" + value + "分";
}
private static String nHour(String value) {
return "第" + value + "时";
}
private static String nDayOfMonth(String value) {
return "第" + value + "日";
}
private static String nMonth(String value) {
return month(value);
}
private static String nMonthStep(String value) {
return "第" + value + "个月";
}
private static String nDayOfWeek(String value) {
return week(value);
}
private static String normalize(String cron) {
return CollectionUtil.foldLeft(
cron.toUpperCase(),
CollectionUtil.concatLists(monthMap(), weekMap()),
(init, elem) -> init.replace(elem._1(), elem._2().toString())
);
}
private static Boolean isStep(String value) {
return value.contains("/");
}
private static Boolean isRange(String value) {
return value.contains("-");
}
private static Boolean hasMultipleValue(String value) {
return value.contains(",");
}
private static Boolean isHash(String value) {
return value.contains("#");
}
private static Boolean isLast(String value) {
return value.equals("L");
}
private static Boolean isLastWithOffset(String value) {
return value.matches("L-([1-9][0-9]*)");
}
private static Boolean isLastWithDayOfWeek(String value) {
return value.matches("([0-7])L");
}
private static Boolean isWeekday(String value) {
return value.contains("W");
}
private static Boolean isLastWeekday(String value) {
return value.equals("LW");
}
private static Boolean isAny(String value) {
return value.equals("*") || value.equals("?");
}
private static String parseHash(String value) {
var pattern = Pattern.compile("([1-7])#([1-5])");
var matcher = pattern.matcher(value);
if (matcher.find()) {
var dayOfWeek = matcher.group(1);
var occurrence = matcher.group(2);
return "第" + occurrence + "个" + week(dayOfWeek);
} else {
throw new RuntimeException("parseHash() called with value " + value);
}
}
private static String lastWeekday() {
return "最后一个工作日";
}
private static String parseWeekday(String value) {
var pattern = Pattern.compile("(\\d+)W");
var matcher = pattern.matcher(value);
if (matcher.find()) {
var dayOfMonth = matcher.group(1);
return "离" + dayOfMonth + "号最近的工作日";
} else {
throw new RuntimeException("parseWeekday() called with value: " + value);
}
}
private static String lastWithOffset(String value) {
var pattern = Pattern.compile("L-([1-9][0-9]*)");
var matcher = pattern.matcher(value);
if (matcher.find()) {
var offset = matcher.group(1);
return "离月底倒数第" + offset + "天";
} else {
throw new RuntimeException("lastWithOffset() called with value: " + value);
}
}
private static String lastWithDayOfWeek(String value) {
var pattern = Pattern.compile("([0-7])L");
var matcher = pattern.matcher(value);
if (matcher.find()) {
var dayOfWeek = matcher.group(1);
return "最后一个" + week(dayOfWeek);
} else {
throw new RuntimeException("lastWithDayOfWeek() called with value: " + value);
}
}
private static String lastDayOfMonth() {
return "最后一天";
}
private static String lastDayOfWeek() {
return "最后一个星期日";
}
private static String parseRange(String value, Function<String, String> valueTransformer) {
var pattern = Pattern.compile("(\\d+)-(\\d+)");
var matcher = pattern.matcher(value);
if (matcher.find()) {
var rangeStart = matcher.group(1);
var rangeEnd = matcher.group(2);
return "从" + valueTransformer.apply(rangeStart) + "到" + valueTransformer.apply(rangeEnd);
} else {
throw new RuntimeException("parseRange() called with value " + value);
}
}
private static String parseStep(String value, String field) {
var valueTransformer = pickValueTransformer(field);
var pattern = Pattern.compile("^(.*?)/(\\d+)$");
var matcher = pattern.matcher(value);
if (matcher.find()) {
var stepStart = matcher.group(1);
var step = matcher.group(2);
var stepResult = field.equals("MONTH") ? nMonthStep(step) : parseValue(step, field);
if (isAny(stepStart)) {
return "每" + valueTransformer.apply(step);
} else if (!isRange(stepStart)) {
return "从" + parseValue(stepStart, field) + "开始的每" + stepResult;
} else {
return parseValue(stepStart, field) + "的每" + stepResult;
}
} else {
throw new RuntimeException("parseStep() called with value " + value);
}
}
private static Function<String, String> pickValueTransformer(String field) {
return switch (field) {
case "SECOND" -> CronWrapper::nSecond;
case "MINUTE" -> CronWrapper::nMinute;
case "HOUR" -> CronWrapper::nHour;
case "DAY_OF_MONTH" -> CronWrapper::nDayOfMonth;
case "MONTH" -> CronWrapper::nMonth;
case "DAY_OF_WEEK" -> CronWrapper::nDayOfWeek;
default -> CronWrapper::asIs;
};
}
private static String parseValue(String value, String field) {
var valueTransformer = pickValueTransformer(field);
if (hasMultipleValue(value)) {
return Arrays.stream(value.split(","))
.map(String::trim)
.distinct()
.map(z -> parseValue(z, field))
.collect(Collectors.joining(","));
} else {
if (isStep(value)) {
return parseStep(value, field);
} else if (isRange(value)) {
return parseRange(value, valueTransformer);
} else if (isAny(value)) {
return "每";
} else {
return valueTransformer.apply(value);
}
}
}
private static String parseValueWithSpecialNotation(String value, String field) {
var valueTransformer = pickValueTransformer(field);
if (hasMultipleValue(value)) {
return Arrays.stream(value.split(","))
.map(String::trim)
.distinct()
.map(z -> parseValueWithSpecialNotation(z, field))
.collect(Collectors.joining(","));
} else {
if (isLastWeekday(value)) {
return lastWeekday();
} else if (isLastWithOffset(value)) {
return lastWithOffset(value);
} else if (isLastWithDayOfWeek(value)) {
return lastWithDayOfWeek(value);
} else if (isLast(value)) {
if (field.equals("DAY_OF_WEEK")) {
return lastDayOfWeek();
} else {
return lastDayOfMonth();
}
} else if (isWeekday(value)) {
return parseWeekday(value);
} else if (isHash(value)) {
return parseHash(value);
} else {
return parseValue(value, field);
}
}
}
public Boolean ignoreDateSection() {
if (isAny(dayOfMonth) && isAny(month) && isAny(dayOfWeek)) {
if (isAny(hour) && isAny(minute)) {
return true;
} else if (isAny(minute) && isAny(second)) {
return true;
} else if (isAny(minute) && second.equals("0")) {
return true;
} else if (isAny(hour) && minute.equals("0")) {
return true;
} else if (isAny(hour) && second.equals("0")) {
return true;
} else return isAny(second) && isAny(month);
} else {
return false;
}
}
public String humanReadableFormat() {
var dateSection = dateSection();
var timeSection = timeSection();
if (ignoreDateSection()) {
return timeSection;
} else {
return dateSection + "的" + timeSection;
}
}
private String dateSection() {
if (isAny(dayOfMonth) && isAny(month) && isAny(dayOfWeek)) {
return "每天";
} else {
if (isAny(dayOfMonth)) {
return monthSection() + dayOfWeekSection();
} else {
return monthSection() + dayOfMonthSection();
}
}
}
private String monthSection() {
if (isAny(month)) {
return "每月";
} else {
return parseValueWithSpecialNotation(month, "MONTH");
}
}
private String dayOfMonthSection() {
if (isLast(dayOfMonth)) {
return "的" + lastDayOfMonth();
} else if (isAny(dayOfMonth)) {
return "";
} else {
return "的" + parseValueWithSpecialNotation(dayOfMonth, "DAY_OF_MONTH");
}
}
private String dayOfWeekSection() {
if (isLast(dayOfWeek)) {
return "的" + lastDayOfWeek();
} else if (isAny(dayOfWeek)) {
return "";
} else {
return "的" + parseValueWithSpecialNotation(dayOfWeek, "DAY_OF_WEEK");
}
}
private String timeSection() {
if (isAny(second) && isAny(minute) && isAny(hour)) {
return "每秒";
} else {
var minuteSection = minuteSection();
var secondSection = secondSection();
return hourSection() + (minuteSection.isBlank() ? minuteSection : "的") + minuteSection() +
(secondSection.isBlank() ? secondSection : "的") + secondSection();
}
}
private String secondSection() {
if (second.equals("0")) {
return "";
} else if (isAny(second)) {
return "每秒";
} else {
return parseValue(second, "SECOND");
}
}
private String minuteSection() {
if (second.equals("0")) {
if (minute.equals("0")) {
return "";
} else if (isAny(minute)) {
return "每个整分";
} else {
return parseValue(minute, "MINUTE") + "整";
}
} else {
if (minute.equals("0")) {
return "第一分钟";
} else if (isAny(minute)) {
return "每分钟";
} else {
return parseValue(minute, "MINUTE");
}
}
}
private String hourSection() {
if (second.equals("0") && minute.equals("0")) {
if (isAny(hour)) {
return "每个整点";
} else {
return parseValue(hour, "HOUR") + "整";
}
} else if (isAny(hour)) {
return "每小时";
} else {
return parseValue(hour, "HOUR");
}
}
}
写完了才想起来两个parseValue完全是可以合并的,明儿再说了。Ghost也是邪门儿,预览的时候MarkDown的部分渲染不出,甚至展示的是某个draft版本,都不是最新的。连打字也打不进去了。估计是有什么大bug。不管了,洗洗睡。