作者:文镭(依来)

前言

这篇文章不是东西推荐,也不是运用事例共享。其主题思想,是介绍一种全新的规划办法。它既具有抽象的数学美感,仅仅从一个简略接口动身,就能推演出巨大的特性调集,引出许多全新概念。一起也有厚实的工程有用价值,由其完结的东西,功用均可明显超过同类的头部开源产品。

这一规划办法并非因Java而生,而是诞生于一个十分粗陋的脚本言语。它对言语特性的要求十分之低,因而其价值对许多现代编程言语都是普适的。

关于Stream

首要大约回顾下Java里传统的流式API。自Java8引入lambda表达式和Stream以来,Java的开发快捷性有了质的飞跃,Stream在杂乱事务逻辑的处理上让人效率倍增,是每一位Java开发者都应该把握的基础技能。但排除掉parallelStream也即并发流之外,它其实并不是一个好的规划。

第一、封装过重,完结过于杂乱,源码极端难读。我能了解这或许是为了兼容并发流所做的妥协,但究竟耦合太深,显得艰深晦涩。每一位初学者被源码吓到之后,想必都会发生流是一种十分高级且完结杂乱的特性的印象。实践上并不是这样,流其实能够用十分简略的办法构建

第二、API过于冗长。冗长体现在stream.collect这一部分。作为比照,Kotlin供给的toList/toSet/associate(toMap)等等丰厚操作是能够直接作用在流上的。Java直到16才抠抠索索加进来一个Stream能够直接调用的toList,他们乃至不肯把toSet/toMap一同加上。

第三、API功用粗陋。关于链式操作,在最初的Java8里只需map/filter/skip/limit/peek/distinct/sorted这七个,Java9又加上了takeWhile/dropWhile。然而在Kotlin中,除了这几个之外人还有许多额定的有用功用。

例如:

mapIndexed,mapNotNull,filterIndexed,filterNotNull,onEachIndexed,distinctBy, sortedBy,sortedWith,zip,zipWithNext等等,翻倍了不止。这些东西完结起来并不杂乱,便是个随手的事,但关于用户而言有和没有的体会差异可谓巨大。

在这篇文章里,我将提出一种全新的机制用于构建流。这个机制极端简略,任何能看懂lambda表达式(闭包)的同学都能亲手完结,任何支撑闭包的编程言语都能运用该机制完结自己的流。也正是因为这个机制满足简略,所以开发者能够以适当低的本钱撸出很多的有用API,运用体会甩开Stream两条街,不是问题。

关于生成器

生成器(Generator)[1]是许多现代编程言语里一个广受好评的重要特性,在Python/Kotlin/C#/Javascript等等言语中均有直接支撑。它的中心API便是一个yield关键字(或许办法)。

有了生成器之后,无论是iterable/iterator,仍是一段乱七八糟的闭包,都能够直接映射为一个流。举个比方,假设你想完结一个下划线字符串转驼峰的办法,在Python里你能够运用生成器这么玩

def underscore_to_camelcase(s):
    def camelcase():
        yield str.lower
        while True:
            yield str.capitalize
    return ''.join(f(sub) for sub, f in zip(s.split('_'), camelcase()))

这短短几行代码能够说处处体现出了Python生成器的巧妙。首要,camelcase办法里出现了yield关键字,解说器就会将其看作是一个生成器,这个生成器会首要供给一个lower函数,然后供给无数的capitalize函数。因为生成器的履行始终是lazy的,所以用while true的办法生成无限流对错常常见的手段,不会有功用或许内存上的糟蹋。其次,Python里的流是能够和list一同进行zip的,有限的list和无限的流zip到一同,list结束了流天然也会结束。

这段代码中,末尾那行join()括号里的东西,Python称之为生成器推导(GeneratorComprehension)[2],其实质上仍然是一个流,一个zip流被map之后的string流,终究经过join办法聚合为一个string。

以上代码里的操作,在任何支撑生成器的言语里都能够简略完结,可是在Java里你恐怕连想都不敢想。Java有史以来,无论是历久弥新的Java8,仍是最新的引入了ProjectLoom[3]的OpenJDK19,连协程都有了,仍然没有直接支撑生成器。

实质上,生成器的完结要依赖于continuation[4]的挂起和恢复,所谓continuation能够直观了解为程序履行到指定方位后的断点,协程便是指在这个函数的断点挂起后跳到另一个函数的某个断点持续履行,而不会堵塞线程,生成器亦如是。

Python经过栈帧的保存与恢复完结函数重入以及生成器[5],Kotlin在编译阶段运用CPS(ContinuationPassingStyle)[6]技能对字节码进行了变换,从而在JVM上模拟了协程[7]。其他的言语要么大体如此,要么有更直接的支撑。

那么,有没有一种办法,能够在没有协程的Java里,完结或许至少模拟出一个yield关键字,从而动态且高功用地创立流呢。答案是,有。

正文

Java里的流叫Stream,Kotlin里的流叫Sequence。我真实想不出更好的姓名了,想叫Flow又被用了,简略起见姑且叫Seq。

概念界说

首要给出Seq的接口界说

public interface Seq<T> {
    void consume(Consumer<T> consumer);
}

它实质上便是一个consumer of consumer,其真实含义我后边会讲。这个接口看似抽象,实则十分常见,java.lang.Iterable天然自带了这个接口,那便是咱们耳熟能详的forEach。运用办法推导,咱们能够写出第一个Seq的实例

List<Integer> list = Arrays.asList(1, 2, 3);
Seq<Integer> seq = list::forEach;

能够看到,在这个比方里consume和forEach是彻底等价的,事实上这个接口我最早便是用forEach命名的,几轮迭代之后才改成含义更准确的consume。

运用单办法接口在Java里会主动识别为FunctionalInteraface这一巨大特性,咱们也能够用一个简略的lambda表达式来结构流,比方只需一个元素的流。

static <T> Seq<T> unit(T t) {
    return c -> c.accept(t);
}

这个办法在数学上很重要(实操上其有用的不多),它界说了Seq这个泛型类型的单位元操作,即T -> Seq的映射。

map与flatMap

map

从forEach的直观角度动身,咱们很简略写出map[8],将类型为T的流,转换为类型为E的流,也即依据函数T -> E得到Seq-> Seq的映射。

default <E> Seq<E> map(Function<T, E> function) {
  return c -> consume(t -> c.accept(function.apply(t)));
}

flatMap

同理,能够持续写出flatMap,即将每个元素展开为一个流之后再合并。

default <E> Seq<E> flatMap(Function<T, Seq<E>> function) {
    return c -> consume(t -> function.apply(t).consume(c));
}

咱们能够自己在IDEA里写写这两个办法,结合智能提示,写起来其实十分便利。假如你觉得了解起来不太直观,就把Seq看作是List,把consume看作是forEach就好。

filter与take/drop

map与flatMap供给了流的映射与组合才干,流还有几个中心才干:元素过滤与中止操控。

filter

过滤元素,完结起来也很简略

default Seq<T> filter(Predicate<T> predicate) {
    return c -> consume(t -> {
        if (predicate.test(t)) {
            c.accept(t);
        }
    });
}

take

流的中止操控有许多场景,take是最常见的场景之一,即获取前n个元素,后边的不要——等价于Stream.limit。

因为Seq并不依赖iterator,所以有必要经过反常完结中止。为此需求构建一个大局单例的专用反常,一起撤销这个反常对调用栈的捕获,以削减功用开支(因为是大局单例,不撤销也不要紧)

public final class StopException extends RuntimeException {
    public static final StopException INSTANCE = new StopException();
    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }
}

以及相应的办法

static <T> T stop() {
    throw StopException.INSTANCE;
}
default void consumeTillStop(C consumer) {
    try {
        consume(consumer);
    } catch (StopException ignore) {}
}

然后就能够完结take了:

default Seq<T> take(int n) {
    return c -> {
        int[] i = {n};
        consumeTillStop(t -> {
            if (i[0]-- > 0) {
                c.accept(t);
            } else {
                stop();
            }
        });
    };
}

drop

drop是与take对应的概念,丢弃前n个元素——等价于Stream.skip。它并不触及流的中止操控,反而更像是filter的变种,一种带有状况的filter。观察它和上面take的完结细节,内部随着流的迭代,存在一个计数器在不断改写状况,但这个计数器并不能为外界感知。这儿其完结已能体现出流的干净特性,它哪怕携带了状况,也丝毫不会显露。

default Seq<T> drop(int n) {
    return c -> {
        int[] a = {n - 1};
        consume(t -> {
            if (a[0] < 0) {
                c.accept(t);
            } else {
                a[0]--;
            }
        });
    };
}

其他API

onEach

对流的某个元素添加一个操作consumer,可是不履行流——对应Stream.peek。

default Seq<T> onEach(Consumer<T> consumer) {
    return c -> consume(consumer.andThen(c));
}

zip

流与一个iterable元素两两聚合,然后转换为一个新的流——在Stream里没有对应,但在Python里有同名完结。

default <E, R> Seq<R> zip(Iterable<E> iterable, BiFunction<T, E, R> function) {
    return c -> {
        Iterator<E> iterator = iterable.iterator();
        consumeTillStop(t -> {
            if (iterator.hasNext()) {
                c.accept(function.apply(t, iterator.next()));
            } else {
                stop();
            }
        });
    };
}

终端操作

上面完结的几个办法都是流的链式API,它们将一个流映射为另一个流,但流自身仍然是lazy或许说尚未真实履行的。真实履行这个流需求运用所谓终端操作,对流进行消费或许聚合。在Stream里,消费便是forEach,聚合便是Collector。关于Collector,其实也能够有更好的规划,这儿就不展开了。不过为了示例,能够先简略快速完结一个join。

default String join(String sep) {
    StringJoiner joiner = new StringJoiner(sep);
    consume(t -> joiner.add(t.toString()));
    return joiner.toString();
}

以及toList。

default List<T> toList() {
    List<T> list = new ArrayList<>();
    consume(list::add);
    return list;
}

至此为止,咱们仅仅只用几十行代码,就完结出了一个五脏俱全的流式API。在大部分情况下,这些API现已能掩盖百分之八九十的运用场景。你彻底能够依样画葫芦,在其他编程言语里照着玩一玩,比方Go(笑)。

生成器的推导

本文尽管从标题开始就在讲生成器,乃至毫不夸大的说生成器才是最中心的特性,但等到把几个中心的流式API写完了,仍然没有解说生成器到底是咋回事——其实倒也不是我在卖关子,你只需细心观察一下,生成器早在最开始讲到Iterable天然生成便是Seq的时分,就现已出现了。

List<Integer> list = Arrays.asList(1, 2, 3);
Seq<Integer> seq = list::forEach;

没看出来?那把这个办法推导改写为普通lambda函数,有

Seq<Integer> seq = c -> list.forEach(c);

再进一步,把这个forEach替换为更传统的for循环,有

Seq<Integer> seq = c -> {
    for (Integer i : list) {
        c.accept(i);
    }
};

因为已知这个list便是[1,2,3],所以以上代码能够进一步等价写为

Seq<Integer> seq = c -> {
    c.accept(1);
    c.accept(2);
    c.accept(3);
};

是不是有点眼熟?无妨看看Python里相似的东西长啥样:

def seq():
    yield 1
    yield 2
    yield 3

二者比较照,办法简直能够说如出一辙——这其实就现已是生成器了,这段代码里的accept就扮演了yield的人物,consume这个接口之所以取这个姓名,含义便是指它是一个消费操作,一切的终端操作都是依据这个消费操作完结的。功用上看,它彻底等价于Iterable的forEach,之所以又不直接叫forEach,是因为它的元素并不是自身自带的,而是经过闭包内的代码块暂时生成的

这种生成器,并非传统意义上运用continuation挂起的生成器,而是运用闭包来捕获代码块里暂时生成的元素,哪怕没有挂起,也能高度模拟传统生成器的用法和特性。其实上文一切链式API的完结,实质上也都是生成器,只不过生成的元素来自于原始的流算了。

有了生成器,咱们就能够把前文说到的下划线转驼峰的操作用Java也依样画葫芦写出来了。

static String underscoreToCamel(String str) {
    // Java没有首字母大写办法,随便现写一个
    UnaryOperator<String> capitalize = s -> s.substring(0, 1).toUpperCase() + s.substring(1).toLowerCase();
     // 运用生成器结构一个办法的流
    Seq<UnaryOperator<String>> seq = c -> {
        // yield第一个小写函数
        c.accept(String::toLowerCase);
        // 这儿IDEA会告警,提示死循环风险,无视即可
        while (true) {
            // 按需yield首字母大写函数
            c.accept(capitalize);
        }
    };
    List<String> split = Arrays.asList(str.split("_"));
    // 这儿的zip和join都在上文给出了完结
    return seq.zip(split, (f, sub) -> f.apply(sub)).join("");
}

咱们能够把这几段代码拷下来跑一跑,看它是不是真的完结了其目标功用。

生成器的实质

尽管现已推导出了生成器,但好像仍是有点摸不着头脑,这中心到底发生了什么,死循环是咋跳出的,怎样就能生成元素了。为了进一步解说,这儿再举一个咱们了解的比方。

出产者-顾客办法

出产者与顾客的联系不止出现在多线程或许协程语境下,在单线程里也有一些经典场景。比方A和B两名同学合作一个项目,别离开发两个模块:A担任产出数据,B担任运用数据。A不关怀B怎样处理数据,或许要先过滤一些,进行聚合后再做核算,也或许是写到某个本地或许远程的存储;B天然也不关怀A的数据是怎样来的。这儿边唯一的问题在于,数据条数真实是太多了,内存一次性放不下。在这种情况下,传统的做法是让A供给一个带回调函数consumer的接口,B在调用A的时分传入一个详细的consumer。

public void produce(Consumer<String> callback) {
    // do something that produce strings
    // then use the callback consumer to eat them
}

这种依据回调函数的交互办法真实是过于经典了,本来没啥可多说的。可是在现已有了生成器之后,咱们无妨胆子扩大一点略微做一下改造:细心观察上面这个produce接口,它输入一个consumer,回来void——咦,所以它其实也是一个Seq嘛!

Seq<String> producer = this::produce;

接下来,咱们只需求略微调整下代码,就能对这个本来依据回调函数的接口进行一次晋级,将它变成一个生成器。

public Seq<String> produce() {
    return c -> {
        // still do something that produce strings
        // then use the callback consumer to eat them
    };
}

依据这一层抽象,作为出产者的A和作为顾客的B就真实做到彻底的、彻底的解耦了。A只需求把数据出产进程放到生成器的闭包里,期间触及到的一切副作用,例如IO操作等,都被这个闭包彻底阻隔了。B则直接拿到一个干干净净的流,他不需求关怀流的内部细节,当然想关怀也关怀不了,他只用专心于自己想做的作业即可。

更重要的是,A和B尽管在操作逻辑上彻底解耦,互相不可见,但在CPU调度时刻上它们却是互相交织的,B乃至还能直接堵塞、中止A的出产流程——能够说没有协程,胜似协程。

至此,咱们总算成功发现了Seq作为生成器的真实实质 :consumer of callback。分明是一个回调函数的顾客,摇身一变就成了出产者,真实是有点美妙。不过细心一想倒也合理:能够满意顾客需求(callback)的家伙,不论这需求有多么奇怪,可不便是出产者么。

简略发现,依据callback机制的生成器,其调用开支彻底就只需生成器闭包内部那堆代码块的履行开支,加上一点点微不足道的闭包创立开支。在诸多触及到流式核算与操控的事务场景里,这将带来极为明显的内存与功用优势。后边我会给出展示其功用优势的详细场景实例。

别的,观察这段改造代码,会发现produce输出的东西,根本就仍是个函数,没有任何数据被真实履行和产出。这便是生成器作为一个匿名接口的天然生成优势:慵懒核算——顾客看似得到了整个流,实践那仅仅一张爱的号码牌,能够涂写,能够抛弃,但只需在拿着名副其实的callback去兑换的那一刻,才会真实的履行流。

生成器的实质,正是人类实质的反面:鸽子克星——没有任何人能够鸽它

IO阻隔与流输出

Haskell发明了所谓IOMonad[9]来将IO操作与纯函数的国际阻隔。Java运用Stream,牵强做到了相似的封装效果。以java.io.BufferedReader为例,将本地文件读取为一个Stream,能够这么写:

Stream<String> lines = new BufferedReader(new InputStreamReader(new FileInputStream("file"))).lines();

假如你细心查看一下这个lines办法的完结,会发现它运用了大段代码去创立了一个iterator,而后才将其转变为stream。暂且不提它的完结有多么繁琐,这儿首要应该留意的是BufferedReader是一个Closeable,安全的做法是在运用完毕后close,或许运用try-with-resources语法包一层,完结主动close。可是BufferedReader.lines并没有去关闭这个源,它是一个不那么安全的接口——或许说,它的阻隔是不完整的。Java对此也打了个补丁,运用java.nio.file.Files.lines,它会添加加一个onClose的回调handler,保证stream耗尽后履行关闭操作。

那么有没有更普适做法呢,究竟不是一切人都清楚BufferedReader.lines和Files.lines会有这种安全性上的差异,也不是一切的Closeable都能供给相似的安全关闭的流式接口,乃至大约率压根就没有流式接口。

好在现在咱们有了Seq,它的闭包特性自带阻隔副作用的先天优势。恰巧在触及很多数据IO的场景里,运用callback交互又是极为经典的规划办法——这儿简直便是它大展拳脚的最佳舞台。

用生成器完结IO的阻隔十分简略,只需求整个包住try-with-resources代码即可,它一起就包住了IO的整个生命周期。

Seq<String> seq = c -> {
    try (BufferedReader reader = Files.newBufferedReader(Paths.get("file"))) {
        String s;
        while ((s = reader.readLine()) != null) {
            c.accept(s);
        }
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
};

中心代码其实就3行,构建数据源,挨个读数据,然后yield(即accept)。后续对流的任何操作看似发生在创立流之后,实践履行起来都被包进了这个IO生命周期的内部,读一个消费一个,互相替换,随用随走。

换句话讲,生成器的callback机制,保证了哪怕Seq能够作为变量四处传递,但触及到的任何副作用操作,都是包在同一个代码块里慵懒履行的。它不需求像Monad那样,还得界说比方IOMonad,StateMonad等等把戏许多的Monad。

与之相似,这儿无妨再举个阿里中心件的比方,运用Tunnel将咱们了解的ODPS表数据下载为一个流:

public static Seq<Record> downloadRecords(TableTunnel.DownloadSession session) {
    return c -> {
        long count = session.getRecordCount();
        try (TunnelRecordReader reader = session.openRecordReader(0, count)) {
            for (long i = 0; i < count; i++) {
                c.accept(reader.read());
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    };
}

有了Record流之后,假如再能完结出一个map函数,就能够十分便利的将Record流map为带事务语义的DTO流——这其实就等价于一个ODPS Reader。

异步流

依据callback机制的生成器,除了能够在IO领域大展拳脚,它天然也是亲和异步操作的。究竟一听到回调函数这个词,许多人就能条件反射式的想到异步,想到Future。一个callback函数,它的命运就决议了它是不会在乎自己被放到哪里、被怎样运用的。比方说,丢给某个暴力的异步逻辑:

public static Seq<Integer> asyncSeq() {
    return c -> {
        CompletableFuture.runAsync(() -> c.accept(1));
        CompletableFuture.runAsync(() -> c.accept(2));
    };
}

这便是一个简略而粗犷的异步流生成器。关于外部运用者来说,异步流除了不能保证元素顺序,它和同步流没有任何差异,实质上都是一段可运转的代码,边运转边发生数据。 一个callback函数,谁给用不是用呢。

并发流

既然给谁用不是用,那么给ForkJoinPool用怎么?——Java大名鼎鼎的parallelStream便是依据ForkJoinPool完结的。咱们也能够拿来搞一个自己的并发流。详细做法很简略,把上面异步流示例里的CompletableFuture.runAsync换成ForkJoinPool.submit即可,仅仅要额定留意一件事:parallelStream终究履行后是要堵塞的(比方最常用的forEach),它并非单纯将任务提交给ForkJoinPool,而是在那之后还要做一遍join。

对此咱们无妨选用最为暴力而简略的思路,结构一个ForkJoinTask的list,顺次将元素提交forkJoinPool后,发生一个task并添加进这个list,等一切元素悉数提交完毕后,再对这个list里的一切task一致join。

default Seq<T> parallel() {
    ForkJoinPool pool = ForkJoinPool.commonPool();
    return c -> map(t -> pool.submit(() -> c.accept(t))).cache().consume(ForkJoinTask::join);
}

这便是依据生成器的并发流,它的完结仅仅只需求两行代码——正如本文开篇所说,流能够用十分简略的办法构建。哪怕是Stream费了老迈劲的并发流,换一种办法,完结起来能够简略到令人发指。

这儿值得再次强调的是,这种机制并非Java限定,而是任何支撑闭包的编程言语都能玩。事实上,这种流机制的最早验证和完结,便是我在AutoHotKey_v2[10]这个软件自带的粗陋的脚本言语上完结的。

再谈出产者-顾客办法

前面为了解说生成器的callback实质,引入了单线程下的出产者-顾客办法。那在完结了异步流之后,作业就更有意思了。

回想一下,Seq作为一种中心数据结构,能够彻底解耦出产者与顾客,一方只管出产数据交给它,另一方只管从它那里拿数据消费。这种结构有没有觉得有点眼熟?不错,正是Java开发者常见的堵塞行列,以及支撑协程的言语里的通道(Channel) ,比方Go和Kotlin。

通道某种意义上也是一种堵塞行列,它和传统堵塞行列的首要差异,在于当通道里的数据超出约束或为空时,对应的出产者/顾客会挂起而不是堵塞,两种办法都会暂停出产/消费,仅仅协程挂起后能让出CPU,让它去别的协程里持续干活。

那Seq比较Channel有什么优势呢?优势可太多了:首要,生成器闭包里callback的代码块,严厉保证了出产和消费必定替换履行,也即严厉的先进先出、进了就出、不进不出,所以不需求单独开辟堆内存去维护一个行列,那没有行列天然也就没有锁,没有锁天然也就没有堵塞或挂起。其次,Seq实质上是消费监听出产,没有出产天然没有消费,假如出产过剩了——啊,出产永远不会过剩,因为Seq是慵懒的,哪怕出产者在那儿while死循环无限出产,也不过是个司空见惯的无限流算了。

这便是生成器的另一种了解办法,一个无行列、无锁、无堵塞的通道。Go言语channel常被诟病的死锁和内存走漏问题,在Seq身上压根就不存在;Kotlin搞出来的异步流Flow和同步流Sequence这两套迥然不同的API,都能被Seq一致替换。

能够说,没有比Seq更安全的通道完结了,因为根本就没有安全问题。出产了没有消费?Seq本来便是慵懒的,没有消费,那就啥也不会出产。消费完了没有关闭通道?Seq本来就不需求关闭——一个lambda罢了有啥好关闭的。

为了更直观的了解,这儿给一个简略的通道示例。先随便完结一个依据ForkJoinPool的异步消费接口,该接口允许用户自在挑选消费完后是否join。

default void asyncConsume(Consumer<T> consumer) {
    ForkJoinPool pool = ForkJoinPool.commonPool();
    map(t -> pool.submit(() -> consumer.accept(t))).cache().consume(ForkJoinTask::join);
}

有了异步消费接口,立马就能够演示出Seq的通道功用。

@Test
public void testChan() {
    // 出产无限的天然数,放入通道seq,这儿流自身便是通道,同步流仍是异步流都无所谓
    Seq<Long> seq = c -> {
        long i = 0;
        while (true) {
            c.accept(i++);
        }
    };
    long start = System.currentTimeMillis();
    // 通道seq交给顾客,顾客表明只需偶数,只需5个
    seq.filter(i -> (i & 1) == 0).take(5).asyncConsume(i -> {
        try {
            Thread.sleep(1000);
            System.out.printf("produce %d and consume\n", i);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    });
    System.out.printf("elapsed time: %dms\n", System.currentTimeMillis() - start);
}

运转结果

produce 0 and consume
produce 8 and consume
produce 6 and consume
produce 4 and consume
produce 2 and consume
elapsed time: 1032ms

能够看到,因为消费是并发履行的,所以哪怕每个元素的消费都要花1秒钟,终究总体耗时也就比1秒多一点点。当然,这和传统的通道办法仍是不太相同,比方实践作业线程就有很大差异。更全面的规划是在流的基础上加上无锁非堵塞行列完结正经Channel,能够附带处理Go通道的许多问题一起提高功用,后边我会另写文章专门评论。

生成器的运用场景

上文介绍了生成器的实质特性,它是一个consumer of callback,它能够以闭包的办法完美封装IO操作,它能够无缝切换为异步流和并发流,并在异步交互中扮演一个无锁的通道人物。除掉这些中心特性带来的优势外,它还有十分多风趣且有价值的运用场景。

树遍历

一个callback函数,它的命运就决议了它是不会在乎自己被放到哪里、被怎样运用的,比方说,放进递归里。而递归的一个典型场景便是树遍历。作为比照,无妨先看看在Python里怎样运用yield遍历一棵二叉树的:

def scan_tree(node):
    yield node.value
    if node.left:
        yield from scan_tree(node.left)
    if node.right:
        yield from scan_tree(node.right)

关于Seq,因为Java不允许函数内部套函数,所以要略微多写一点。中心原理其实很简略,把callback函数丢给递归函数,每次递归记住捎带上就行。

//static <T> Seq<T> of(T... ts) {
//    return Arrays.asList(ts)::forEach;
//}
// 递归函数
public static <N> void scanTree(Consumer<N> c, N node, Function<N, Seq<N>> sub) {
    c.accept(node);
    sub.apply(node).consume(n -> {
        if (n != null) {
            scanTree(c, n, sub);
        }
    });
}
// 通用办法,能够遍历任何树
public static <N> Seq<N> ofTree(N node, Function<N, Seq<N>> sub) {
    return c -> scanTree(c, node, sub);
}
// 遍历一个二叉树
public static Seq<Node> scanTree(Node node) {
    return ofTree(node, n -> Seq.of(n.left, n.right));
}

这儿的ofTree便是一个十分强壮的树遍历办法。遍历树自身并不是啥稀罕东西,但把遍历的进程输出为一个流,那幻想空间就很大了。在编程言语的国际里树的结构能够说处处都是。比方说,咱们能够十分简略的结构出一个遍历JSONObject的流。

static Seq<Object> ofJson(Object node) {
    return Seq.ofTree(node, n -> c -> {
        if (n instanceof Iterable) {
            ((Iterable<?>)n).forEach(c);
        } else if (n instanceof Map) {
            ((Map<?, ?>)n).values().forEach(c);
        }
    });
}

然后剖析JSON就会变得十分便利,比方你想校验某个JSON是否存在Integer字段,不论这个字段在哪一层。运用流的any/anyMatch这样的办法,一行代码就能搞定:

boolean hasInteger = ofJson(node).any(t -> t instanceof Integer);

这个办法的凶猛之处不只在于它满足简略,更在于它是一个短路操作。用正常代码在一个深度优先的递归函数里履行短路,要不就抛出反常,要不就额定添加一个上下文参数参加递归(只需在回来根节点后才干停止),总归完结起来都挺麻烦。可是运用Seq,你只需求一个any/all/none。

再比方你想校验某个JSON字段里是否存在非法字符串“114514”,相同也是一行代码:

boolean isIllegal = ofJson(node).any(n -> (n instanceof String) && ((String)n).contains("114514"));

对了,JSON的前辈XML也是树的结构,结合许多成熟的XML的解析器,咱们也能够完结出相似的流式扫描东西。比方说,更快的Excel解析器?

更好用的笛卡尔积

笛卡尔积对大部分开发而言或许用途不大,但它在函数式言语中是一种颇为重要的结构,在运筹学领域构建最优化模型时也极端常见。此前Java里若要运用Stream构建多重笛卡尔积,需求多层flatMap嵌套。

public static Stream<Integer> cartesian(List<Integer> list1, List<Integer> list2, List<Integer> list3) {
    return list1.stream().flatMap(i1 ->
        list2.stream().flatMap(i2 ->
            list3.stream().map(i3 -> 
                i1 + i2 + i3)));
}

关于这样的场景,Scala供给了一种语法糖,允许用户以for循环+yield[11]的办法来组合笛卡尔积。不过Scala的yield便是个纯语法糖,与生成器并无直接联系,它会在编译阶段将代码翻译为上面flatMap的办法。这种糖办法上等价于Haskell里的doannotation[12]。

好在现在有了生成器,咱们有了更好的挑选,能够在不增加语法、不引入关键字、不麻烦编译器的前提下,直接写个嵌套for循环并输出为流。且办法更为自在——你能够在for循环的任意一层随意添加代码逻辑。

public static Seq<Integer> cartesian(List<Integer> list1, List<Integer> list2, List<Integer> list3) {
    return c -> {
        for (Integer i1 : list1) {
            for (Integer i2 : list2) {
                for (Integer i3 : list3) {
                    c.accept(i1 + i2 + i3);
                }
            }
        }
    };
}

换言之,Java不需求这样的糖。Scala或许本来也能够不要。

或许是Java下最快的CSV/Excel解析器

我在前文屡次强调生成器将带来明显的功用优势,这一观点除了有理论上的支撑,也有清晰的工程实践数据,那便是我为CSV宗族所开发的架构一致的解析器。所谓CSV宗族除了CSV以外,还包含Excel与阿里云的ODPS,其实只需办法契合其一致范式,就都能进入这个宗族。

可是关于CSV这一家子的处理其实一直是Java言语里的一个痛点。ODPS就不说了,好像压根就没有。CSV的库尽管许多,但好像都不是很让人满意,要么API繁琐,要么功用低下,没有一个的位置能与Python里的Pandas相提并论。其间相对闻名一点的有OpenCSV[13],Jackson的jackson-dataformat-csv[14],以及声称最快的univocity-parsers[15]。

Excel则不相同,有集团开源软件EasyExcel[16]珠玉在前,我只能保证比它快,很难也不打算比它功用掩盖全。

关于其间的CsvReader完结,因为市面上相似产品真实太多,我也没精力挨个去比,我只能说反正它比揭露声称最快的那个还要快不少——大约一年前我完结的CsvReader在我作业电脑上的速度最多只能到达univocity-parsers的80%~90%,不论怎样优化也死活拉不上去。直到后来我发现了生成器机制并对其重构之后,速度直接反超前者30%到50% ,成为我已知的相似开源产品里的最快完结。

关于Excel,在给定的数据集上,我完结的ExcelReader比EasyExcel快50%~55% ,跟POI就懒得比了。测试详情见以上链接。

注:最近和Fastjson作者高铁有许多交流,在暂未正式发布的Fastjson2的2.0.28-SNAPSHOT版别上,其CSV完结的功用在多个JDK版别上现已基本追平我的完结。出于严谨,我只能说我的完结在本文发布之前或许是已知最快的哈哈。

改造EasyExcel,让它能够直接输出流

上面说到的EasyExcel是阿里开源的闻名产品,功用丰厚,质量优秀,广受好评。恰好它自身又一个运用回调函数进行IO交互的经典事例,倒是也十分合适拿来作为比方讲讲。依据官网示例,咱们能够结构一个最简略的依据回调函数的excel读取办法

public static <T> void readEasyExcel(String file, Class<T> cls, Consumer<T> consumer) {
    EasyExcel.read(file, cls, new PageReadListener<T>(list -> {
        for (T person : list) {
            consumer.accept(person);
        }
    })).sheet().doRead();
}

EasyExcel的运用是经过回调监听器来捕获数据的。例如这儿的PageReadListener,内部有一个list缓存。缓存满了,就喂给回调函数,然后持续刷缓存。这种依据回调函数的做法的确十分经典,可是不免有一些不便利的当地:

  1. 顾客需求关怀出产者的内部缓存,比方这儿的缓存便是一个list。

  2. 顾客假如想拿走悉数数据,需求放一个list进去挨个add或许每次addAll。这个操作对错慵懒的。

  3. 难以把读取进程转变为Stream,任何流式操作都有必要要用list存完并转为流后,才干再做处理。灵活性很差。

  4. 顾客不便利干预数据出产进程,比方到达某种条件(例如个数)后直接中止,除非你在完结回调监听器时把这个逻辑override进去[17]。

运用生成器,咱们能够将上面示例中读取excel的进程彻底关闭起来,顾客不需求传入任何回调函数,也不需求关怀任何内部细节——直接拿到一个流就好。改造起来也适当简略,主体逻辑原封不动,只需求把那个callback函数用一个consumer再包一层即可:

public static <T> Seq<T> readExcel(String pathName, Class<T> head) {
    return c -> {
        ReadListener<T> listener = new ReadListener<T>() {
            @Override
            public void invoke(T data, AnalysisContext context) {
                c.accept(data);
            }
            @Override
            public void doAfterAllAnalysed(AnalysisContext context) {}
        };
        EasyExcel.read(pathName, head, listener).sheet().doRead();
    };
}

这一改造我现已给EasyExcel官方提了PR[18],不过不是输出Seq,而是依据生成器原理构建的Stream,后文会有构建办法的详细介绍。

更进一步的,彻底能够将对Excel的解析进程改造为生成器办法,运用一次性的callback调用避免内部很多状况的存储与修改,从而带来可观的功用提高。这一作业因为要依赖上文CsvReader的一系列API,所以暂时无法提交给EasyExcel。

用生成器构建Stream

生成器作为一种全新的规划办法,固然能够供给更为强壮的流式API特性,可是究竟不同于咱们最为了解Stream,总会有个适应本钱或许搬迁本钱。关于既有的现已成熟的库而言,运用Stream仍然是对用户最为担任的挑选。值得幸亏的是,哪怕机制彻底不同,Stream和Seq仍是高度兼容的。

首要,显而易见,就好像Iterable那样,Stream天然便是一个Seq:

Stream<Integer> stream = Stream.of(1, 2, 3);
Seq<Integer> seq = stream::forEach;

那反过来Seq能否转化为Stream呢?在Java Stream供给的官方完结里,有一个StreamSupport.stream的结构东西,能够协助用户将一个iterator转化为stream。针对这个进口,咱们其实能够用生成器来结构一个非标准的iterator:不完结hastNext和next,而是单独重载forEachRemaining办法,从而hack进Stream的底层逻辑——在那迷宫一般的源码里,有一个十分隐秘的旮旯,一个叫AbstractPipeline.copyInto的办法,会在真实履行流的时分调用Spliterator的forEachRemaining办法来遍历元素——尽管这个办法本来是经过next和hasNext完结的,但当咱们把它重载之后,就能够做到假狸猫换真太子。

public static <T> Stream<T> stream(Seq<T> seq) {
    Iterator<T> iterator = new Iterator<T>() {
        @Override
        public boolean hasNext() {
            throw new NoSuchElementException();
        }
        @Override
        public T next() {
            throw new NoSuchElementException();
        }
        @Override
        public void forEachRemaining(Consumer<? super T> action) {
            seq.consume(action::accept);
        }
    };
    return StreamSupport.stream(
        Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED),
        false);
}

也便是说,咱现在乃至能用生成器来结构Stream了!比方:

public static void main(String[] args) {
    Stream<Integer> stream = stream(c -> {
        c.accept(0);
        for (int i = 1; i < 5; i++) {
            c.accept(i);
        }
    });
    System.out.println(stream.collect(Collectors.toList()));
}

图灵在上,感谢Stream的作者没有偷这个懒,没有用while hasNext来进行遍历,不然这操作咱还真玩不了。

当然因为这儿的Iterator实质现已发生了改变,这种操作也会有一些约束,无法再运用parallel办法将其转为并发流,也不能用limit办法约束数量。不过除此以外,像map, filter, flatMap, forEach, collect等等办法,只需不触及流的中止,都能够正常运用。

无限递推数列

实践运用场景不多。Stream的iterate办法能够支撑单个种子递推的无限数列,但两个乃至多个种子的递推就无能为力了,比方最受程序员喜欢的炫技专用斐波那契数列:

public static Seq<Integer> fibonaaci() {
    return c -> {
        int i = 1, j = 2;
        c.accept(i);
        c.accept(j);
        while (true) {
            c.accept(j = i + (i = j));
        }
    };
}

别的还有一个比较有意思的运用,运用法里树的特性,进行丢番图逼近[22],简而言之,便是用有理数逼近实数。这是一个十分合适拿来做demo的且满足风趣的比方,限于篇幅原因我就不展开了,有机会另写文章评论。

流的更多特性

流的聚合

怎么规划流的聚合接口是一个很杂乱的话题,若要认真评论简直又能够整出大几千字,限于篇幅这儿简略提几句好了。在我看来,好的流式API应该要让流自身能直接调用聚合函数,而不是像Stream那样,先用Collectors结构一个Collector,再用stream去调用collect。能够比照下以下两种办法,孰优孰劣一望而知:

Set<Integer> set1 = stream.collect(Collectors.toSet());
String string1 = stream.map(Integer::toString).collect(Collectors.joinning(","));
Set<Integer> set2 = seq.toSet();
String string2 = seq.join(",", Integer::toString);

这一点上,Kotlin做的比Java好太多。不过有利往往也有弊,从函数接口而非用户运用的角度来说,Collector的规划其实更为齐备,它关于流和groupBy是同构的:一切能用collector对流直接做到的作业,groupBy之后用相同的collector也能做到,乃至groupBy自身也是一个collector。

所以更好的规划是既保留函数式的齐备性与同构性,一起也供给由流直接调用的快捷办法。为了阐明,这儿举一个Java和Kotlin都没有完结但需求很遍及的比方,求加权均匀:

public static void main(String[] args) {
    Seq<Integer> seq = Seq.of(1, 2, 3, 4, 5, 6, 7, 8, 9);
    double avg1 = seq.average(i -> i, i -> i); // = 6.3333
    double avg2 = seq.reduce(Reducer.average(i -> i, i -> i)); // = 6.3333
    Map<Integer, Double> avgMap = seq.groupBy(i -> i % 2, Reducer.average(i -> i, i -> i)); // = {0=6.0, 1=6.6}
    Map<Integer, Double> avgMap2 = seq.reduce(Reducer.groupBy(i -> i % 2, Reducer.average(i -> i, i -> i)));
}

上面代码里的average,Reducer.average,以及用在groupBy里的average都是彻底同构的,换句话说,同一个Reducer,能够直接用在流上,也能够对流进行分组之后用在每一个子流上。这是一套相似Collector的API,既处理了Collector的一些问题,一起也能供给更丰厚的特性。重点是,这玩意儿是开放的,且机制满足简略,谁都能写。

流的分段处理

分段处理其实是一直以来各种流式API的一个盲点,不论是map仍是forEach,咱们偶然会期望前半截和后半截采取不同的处理逻辑,或许更直接一点的说期望第一个元素特殊处理。对此,我供给了三种API,元素替换replace,分段map,以及分段消费consume。

仍是以前文说到的下划线转驼峰的场景作为一个典型比方:在将下划线字符串split之后,对第一个元素运用lowercase,对剩下的其他元素运用capitalize。运用分段的map函数,能够更快速的完结这一个功用。

static String underscoreToCamel(String str, UnaryOperator<String> capitalize) {
    // split=>分段map=>join
    return Seq.of(str.split("_")).map(capitalize, 1, String::toLowerCase).join("");
}

再举个比方,当你解析一个CSV文件的时分,关于存在表头的情况,在解析时就要别离处理:运用表头信息对字段重排序,剩下的内容则按行转为DTO。运用适当的分段处理逻辑,这一看似麻烦的操作是能够在一个流里一次性完结的。

一次性流仍是可重用流?

了解Stream的同学应该清楚,Stream是一种一次性的流,因为它的数据来源于一个iterator,二次调用一个现已用完的Stream会抛出反常。Kotlin的Sequence则选用了不同的规划理念,它的流来自于Iterable,大部分情况下是可重用的。可是Kotlin在读文件流的时分,选用的仍然是和Stream相同的思路,将BufferedReader封装为一个Iterator,所以也是一次性的。

不同于以上二者,生成器的做法明显要更为灵活,流是否可重用,彻底取决于被生成器包进去的数据源是否可重用。比方上面代码里不论是本地文件仍是ODPS表,只需数据源的构建是在生成器里面完结的,那天然便是可重用的。你能够像运用一个普通List那样,屡次运用同一个流。从这个角度上看,生成器自身便是一个Immutable,它的元素出产,直接来自于代码块,不依赖于运转环境,不依赖于内存状况数据。关于任何顾客而言,都能够等待同一个生成器给出始终一致的流。

生成器的实质和人类相同,都是复读机

当然,复读机复读也是要看本钱的,关于像IO这种高开支的流需求重复运用的场景,重复去做相同的IO操作必定不合理,咱们无妨规划出一个cache办法用于流的缓存。

最常用的缓存办法,是将数据读进一个ArrayList。因为ArrayList自身并没有完结Seq的接口,所以无妨造一个ArraySeq,它既是ArrayList,又是Seq——正如我前面屡次说到的,List天然便是Seq。

public class ArraySeq<T> extends ArrayList<T> implements Seq<T> {
    @Override
    public void consume(Consumer<T> consumer) {
        forEach(consumer);
    }
}

有了ArraySeq之后,就能够立马完结流的缓存

default Seq<T> cache() {
    ArraySeq<T> arraySeq = new ArraySeq<>();
    consume(t -> arraySeq.add(t));
    return arraySeq;
}

细心的朋友或许会留意到,这个cache办法我在前面结构并发流的时分现已用到了。除此以外,凭借ArraySeq,咱们还能简略的完结流的排序,感兴趣的朋友能够自行测验。

二元流

既然能够用consumer of callback作为机制来构建流,那么有意思的问题来了,假如这个callback不是Consumer而是个BiConsumer呢?——答案便是,二元流!

public interface BiSeq<K, V> {
    void consume(BiConsumer<K, V> consumer);
}

二元流是一个全新概念,此前任何依据迭代器的流,比方Java Stream,Kotlin Sequence,还有Python的生成器,等等等等,都玩不了二元流。我倒也不是针对谁,究竟在座诸位的next办法都有必要吐出一个目标实例,意味着即使想结构一起有两个元素的流,也有必要包进一个Pair之类的结构体里——故而其实质上仍然是一个一元流。当流的元素数量很大时,它们的内存开支将十分明显。

哪怕是看起来最像二元流的Python的zip:

for i, j in zip([1, 2, 3], [4, 5, 6]):
    pass

这儿的i和j,实践仍是对一个tuple进行解包之后的结果。

可是依据callback机制的二元流和它们彻底不相同,它和一元流是同等轻量的!这就意味着节省内存一起还快。比方我在完结CsvReader时,重写了String.split办法使其输出为一个流,这个流与DTO字段zip为二元流,就能完结值与字段的1对1匹配。不需求凭借下标,也不需求创立暂时数组或list进行存储。每一个被切割出来的substring,在整个生命周期里都是一次性的,随用随丢。

这儿额定值得一提的是,同Iterable相似,Java里的Map天然生成便是一个二元流。

Map<Integer, String> map = new HashMap<>();
BiSeq<Integer, String> biSeq = map::forEach;

有了依据BiConsumer的二元流,天然也能够有依据TriConsumer三元流,四元流,以及依据IntConsumer、DoubleConsumer等原生类型的流等等。这是一个真实的流的咱们族,里面乃至还有许多不同于一元流的特殊操作,这儿就不过多展开了,只提一个:

二元流和三元流乃至多元流,能够在Java里结构出名副其实的慵懒元组tuple。当你的函数需求回来多个回来值的时分,除了手写一个Pair/Triple,你现在有了更好的挑选,便是用生成器的办法直接回来一个BiSeq/TriSeq,这比直接的元组还额定增加了的慵懒核算的优势,能够在真实需求运用的时分再用回调函数去消费。你乃至连空指针查看都省了。

结束语

首要感谢你能读到这儿,我要讲的故事大体现已讲完了,尽管还有许多称得上风趣的细节没放出来评论,但现已不影响这个故事的完整性了。我想要再次强调的是,上面这一切的内容,代码也好,特性也好,事例也罢,包含我所完结的CsvReader系列——悉数都衍生自这一个简略接口,它是一切的源头,是梦开始的当地,彻底值得我在文末再写一遍

public interface Seq<T> {
    void consume(Consumer<T> consumer);
}

关于这个奇特的接口,我愿称之为:

道生一——先有Seq界说

一生二——导出Seq一体两面的特性,既是流,又是生成器

二生三——由生成器完结出丰厚的流式API,而后导出可安全阻隔的IO流,终究导出异步流、并发流以及通道特性

至于三生万物的部分,还会有后续文章,等待能提前对外开源吧。

附录

附录的本来内容包含API文档,引证地址,以及功用benchmark。因为暂未开源,这儿仅介绍下Monad相关。

Monad

Monad[24]是来自于领域论里的一个概念,一起也是函数式编程言语代表者Haskell里极为重要的一种规划办法。但它无论是对流仍是对生成器而言都不是有必要的,所以放在附录讲。

我之所以要提Monad,是因为Seq在完结了unit, flatMap之后,天然也就成为了一种Monad。关于关注相关理论的同学来说,假如连提都不提,或许会有些难受。惋惜的是,尽管Seq在办法上是个Monad,但它们在理念上是存在一些抵触的。比方说在Monad里至关重要的flatMap,既是中心界说之一,还承担着组合与拆包两大重要功用。乃至连map对Monad来说都不是有必要的,它彻底能够由flatMap和unit推导出来(推导进程见下文),反之还不可。可是关于流式API而言,map才是真实最为关键和高频的操作,flatMap反而没那么重要,乃至压根都不太常用。

Monad这种规划办法之所以被推崇备至,是因为它有几个重要特性,慵懒求值、链式调用以及副作用阻隔——在纯函数的国际里,后者乃至称得上是性命攸关的大事。可是对包含Java在内的大部分正常言语来说,完结慵懒求值更直接的办法是面向接口而不是面向目标(实例)编程,接口因为没有成员变量,天然生成便是慵懒的。链式操作则是流的天然生成特性,无须赘述。至于副作用阻隔,这相同不是Monad的专利。生成器用闭包+callback的办法也能做到,前文都有介绍。

推导map的完结

首要,map能够由unit与flatMap直接组合得到,这儿无妨称之为map2:

default <E> Seq<E> map2(Function<T, E> function) {
    return flatMap(t -> unit(function.apply(t)));
}

即把类型为T的元素,转变为类型为E的Seq,再用flatMap合并。这个是最直观的,不需求流的先验概念,是Monad的固有属性。当然其在效率上必定很差,咱们能够对其化简。

已知unit与flatMap的完结

static <T> Seq<T> unit(T t) {
    return c -> c.accept(t);
}
default <E> Seq<E> flatMap(Function<T, Seq<E>> function) {
    return c -> supply(t -> function.apply(t).supply(c));
}

先展开unit,代入上面map2的完结,有

default <E> Seq<E> map3(Function<T, E> function) {
    return flatMap(t -> c -> c.accept(function.apply(t)));
}

把这个flatMap里面的函数提出来变成flatFunction,再展开flatMap,有

default <E> Seq<E> map4(Function<T, E> function) {
    Function<T, Seq<E>> flatFunction = t -> c -> c.accept(function.apply(t));
    return consumer -> supply(t -> flatFunction.apply(t).supply(consumer));
}

简略留意到,这儿的flatFunction连续有两个箭头,它其实就彻底等价于一个双参数(t,c)函数的柯里化currying。咱们对其做逆柯里化操作,反推出这个双参数函数:

Function<T, Seq<E>> flatFunction = t -> c -> c.accept(function.apply(t));
// 等价于
BiConsumer<T, Consumer<E>> biConsumer = (t, c) -> c.accept(function.apply(t));

能够看到,这个等价的双参数函数其实便是一个BiConsumer,再将其代入map4,有

default <E> Seq<E> map5(Function<T, E> function) {
    BiConsumer<T, Consumer<E>> biConsumer = (t, c) -> c.accept(function.apply(t));
    return c -> supply(t -> biConsumer.accept(t, c));
}

留意到,这儿biConsumer的实参和形参是彻底一致的,所以能够将它的办法体代入下边直接替换,所以有

default <E> Seq<E> map6(Function<T, E> function) {
    return c -> supply(t -> c.accept(function.apply(t)));
}

到这一步,这个map6,就和前文从流式概念动身直接写出来的map彻底一致了。证毕!

参考链接:

[1]en.wikipedia.org/wiki/Genera…

[2]www.pythonlikeyoumeanit.com/Module2_Ess…

[3]openjdk.org/projects/lo…

[4]en.wikipedia.org/wiki/Contin…

[5]hackernoon.com/the-magic-b…

[6]en.wikipedia.org/wiki/Contin…

[7]kotlinlang.org/spec/asynch…

[8]zh.wikipedia.org/wiki/Map_(%…

[9]crypto.stanford.edu/~blynn/hask…

[10]www.autohotkey.com/docs/v2/

[11]stackoverflow.com/questions/1…

[12]stackoverflow.com/questions/1…

[13]opencsv.sourceforge.net/

[14]github.com/FasterXML/j…

[15]github.com/uniVocity/u…

[16]github.com/alibaba/eas…

[17]github.com/alibaba/eas…

[18]github.com/alibaba/eas…

[20]github.com/alibaba/eas…

[21]github.com/alibaba/fas…

[22]www.bilibili.com/video/BV1ha…

[24]en.wikipedia.org/wiki/Monad_…

更多内容,请点击此处进入云原生技能社区查看