人话模式

人话模式
Photo by Drew Beamer / Unsplash

打从去年开始做2.0系统的时候就考虑说要引入“人话模式”。起因是当初1.0上线的时候,有业务同学提出了一个从未设想过的问题。“你们不能用AND/OR/NOT来表示逻辑,要以我们的习惯来”。虽然我是觉得逻辑操作符还没有超出“人话”的范畴,但总之是埋下了个种子。于是最近总算稍微开始做一些了。

每次写代码遇到一些瓶颈的时候,就开始折腾些有的没的。折腾TailScale也差不多是因为这个。这次的切入点是年初写的个调度器。因为实在不想用xxl-job,干脆自己撸了一套。既然是调度器,那首先想到的就是CronTab。这不就好办了,全部用Cron的表达式就好了。但是这东西有个很麻烦的点,就是不太能一眼看明白到底会在什么时间点触发。所以第一步,先给Cron的“人话模式”搞出来。

一开始想着既然CronTab用的这么广泛,这种工具难道不是遍地都是?简单搜了一下还真不是。CronUtils包虽然提供了CronDescriptor这种工具,但是实际用过之后发现,与其去看它输出的“人话”,还不如直接看表达式。当然了,对于市面上的LLM来说,这事情都很简单。但是这么简单的,直接通过规则就能达成的事情,再部署个大(小)模型,似乎不是嗯划得来。当然了,call API也行。LLM卷到这个地步,大家都是白菜价。当然,作为一个靠写代码吃饭的人,还是姑且想自己来。

gray and orange plastic robot toy
Photo by Emilipothèse / Unsplash

下午的时候问了下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。不管了,洗洗睡。