zoukankan      html  css  js  c++  java
  • 指令式Callback,函数式Promise:对node.js的一声叹息

    原文:Callbacks are imperative, promises are functional: Node’s biggest missed opportunity

    promises 天生就不会受不断变化的情况影响。
    -- Frank Underwood, ‘House of Cards’

    人们常说Javascript是'函数式'编程语言。而这仅仅因为函数是它的一等值,可函数式编程的很多其他特性,包括不可变数据,递归比循环更招人待见,代数类型系统,规避副作用等,它都不俱备。尽管把函数作为一等公民确实管用,也让码农可以根据自己的需要决定是否采用函数式的风格编程,但宣称JS是函数式的往往会让JS码农们忽略函数式编程的一个核心理念:用值编程。

    '函数式编程'是一个使用不当的词,因为它会让人们以为这是'用函数编程'的意思,把它跟用对象编程相对比。但如果面向对象编程是把一切都当作对象,那函数式编程是把一切都当作值,不仅函数是值,而是一切都是值。这其中当然包括显而易见的数值、字符串、列表和其它数据,还包括我们这些OOP狗一般不会看成值的其它东西:IO操作和其它副作用,GUI事件流,null检查,甚至是函数调用序列的概念。如果你曾听说过'可编程的分号'1这个短语,你应该就能明白我在说什么了。

    1单子。 In functional programming, a monad is a structure that represents computations. A type with a monad structure defines what it means to chain operations of that type together. This allows the programmer to build pipelines that process data in steps, in which each action is decorated with additional processing rules provided by the monad. As such, monads have been described as "programmable semicolons"; a semicolon is the operator used to chain together individual statements in many imperative programming languages, thus the expression implies that extra code will be executed between the statements in the pipeline. Monads have been also explained with a physical metaphor as assembly lines, where a conveyor belt transports data between functional units that transform it one step at a time. http://en.wikipedia.org/wiki/Monad_(functional_programming)

    最好的函数式编程是声明式的。在指令式编程中,我们编写指令序列来告诉机器如何做我们想做的事情。在函数式编程中,我们描述值之间的关系,告诉机器我们想计算什么,然后由机器自己产生指令序列完成计算。

    用过excel的人都做过函数式编程:在其中通过建模把一个问题描绘成一个值图(如何从一个值推导出另一个)。当插入新值时,Excel负责找出它对图会产生什么影响,并帮你完成所有的更新,而无需你编写指令序列指导它完成这项工作。

    有了这个定义做依据,我要指出node.js一个最大的设计失误,最起码我是这样认为的:在最初设计node.js时,在确定提供哪种方式的API式,它选择了基于callback,而不是基于promise。

    所有人都在用 [callbacks]。如果你发布了一个返回promise的模块,没人会注意到它。人们甚至不会去用那样一个模块。

    如果我要自己写个小库,用来跟Redis交互,并且这是它所做的最后一件事,我可以把传给我的callback转给Redis。而且当我们真地遇到callback hell之类的问题时,我会告诉你一个秘密:这里还有协同hell和单子hell,并且对于你所创建的任何抽象工具,只要你用得足够多,总会遇到某个hell。

    在90%的情况下我们都有这种超级简单的接口,所以当我们需要做某件事的时候,只要小小的缩进一下,就可以搞定了。而在遇到复杂的情况时,你可以像npm里的其它827个模块一样,装上async。

    --Mikeal Rogers, LXJS 2012

    Node宣称它的设计目标是让码农中的屌丝也能轻松写出反应迅速的并发网络程序,但我认为这个美好的愿望撞墙了。用Promise可以让运行时确定控制流程,而不是让码农绞尽脑汁地明确写出来,所以更容易构建出正确的、并发程度最高的程序。

    编写正确的并发程序归根结底是要让尽可能多的操作同步进行,但各操作的执行顺序仍能正确无误。尽管Javascript是单线程的,但由于异步,我们仍然会遇到竞态条件:所有涉及到I/O操作的操作在等待callback时都要把CPU时间让给其他操作。多个并发操作都能访问内存中的相同数据,对数据库或DOM执行重叠的命令序列。借助promise,我们可以像excel那样用值之间的相互关系来描述问题,从而让工具帮你找出最优的解决方案,而不是你亲自去确定控制流。

    我希望澄清大家对promise的误解,它的作用不仅是给基于callback的异步实现找一个语法更清晰的写法。promise以一种全新的方式对问题建模;它要比语法层面的变化更深入,实际上是在语义层上改变了解决问题的方式。

    我在两年前曾写过一篇文章,promises是异步编程的单子。那篇文章的核心理念是单子是组建函数的工具,比如构建一个以上一个函数的输出作为下一个函数输入的管道。这是通过使用值之间的结构化关系来达成的,它的值和彼此之间的关系在这里仍要发挥重要作用。

    我仍将借助Haskell的类型声明来阐明问题。在Haskell中,声明foo::bar表示“foo是类型为bar的值”。声明foo :: Bar -> Qux 的意思是"foo是一个函数,以类型Bar的值为参数,返回一个类型为Qux的值"。如果输入/输出的确切类型无关紧要,可以用单个的小写字母表示,foo :: a -> b。如果foo的参数不止一个,可以加上更多的箭头,比如foo :: a -> b -> c表示foo有两个类型分别为a和b的参数,返回类型为c的值。

    我们来看一个Node函数,就以fs.readFile()为例吧。这个函数的参数是一个String类型的路径名和一个callback函数,它没有任何返回值。callback函数有两个参数,Error(可能为null)和包含文件内容的Buffer,也是没有任何返回值。我们可以把readFile的类型表示为:

    readFile :: String -> Callback -> ()
    

    () 在 Haskell 中表示 null 类型。callback 本身是另一个函数,它的类型签名是:

    Callback :: Error -> Buffer -> ()
    

    把这些都放到一起,则可以说readFile以一个String和一个带着Buffer调用的函数为参数:

    readFile :: String -> (Error -> Buffer -> ()) -> ()
    

    好,现在请想象一下Node使用promises是什么情况。对于readFile而言,就是简单地接受一个String类型的值,并返回一个Buffer的promise值。

    readFile :: String -> Promise Buffer
    

    说得更概括一点,就是基于callback的函数接受一些输入和一个callback,然后用它的输出调用这个callback函数,而基于promise的函数接受输入,返回输出的promise值:

    callback :: a -> (Error -> b -> ()) -> ()
    promise :: a -> Promise b
    

    基于callback的函数返回的那些null值就是基于callback编程之所以艰难的源头:基于callback的函数什么都不返回,所以难以把它们组装到一起。没有返回值的函数,执行它仅仅是因为它的副作用 -- 没有返回值或副作用的函数就是个黑洞。所以用callback编程天生就是指令式的,是编写以副作用为主的过程的执行顺序,而不是像函数应用那样把输入映射到输出。是手工编排控制流,而不是通过定义值之间的关系来解决问题。因此使编写正确的并发程序变得艰难。

    而基于promise的函数与之相反,你总能把函数的结果当作一个与时间无关的值。在调用基于callback的函数时,在你调用这个函数和它的callback被调用之间要经过一段时间,而在这段时间里,程序中的任何地方都找不到表示结果的值。

    fs.readFile('file1.txt',
      // 时光流逝...
      function(error, buffer) {
        // 现在,结果突然跌落在凡间
      }
    );
    

    从基于callback或事件的函数中得到结果基本上就意味着你“要在正确的时间正确的地点”出现。如果你是在事件已经被触发之后才把事件监听器绑定上去,或者把callback放错了位置,那上帝也罩不了你,你只能看着结果从眼前溜走。这对于用Node写HTTP服务器的人来说就像瘟疫一样。如果你搞错了控制流,那你的程序就只能崩溃。

    而Promises与之相反,它不关心时间或者顺序。无论你在promise被resolve之前还是之后附上监听器,都没关系,你总能从中得到结果值。因此,返回promises的函数马上就能给你一个表示结果的值,你可以把它当作一等数据来用,也可以把它传给其它函数。不用等着callback,也不会错过任何事件。只要你手中握有promise,你就能从中得到结果值。

    var p1 = new Promise();
    p1.then(console.log);
    p1.resolve(42);
    
    var p2 = new Promise();
    p2.resolve(2013);
    p2.then(console.log);
    
    // prints:
    // 42
    // 2013
    

    所以尽管then()这个方法的名字让人觉得它跟某种顺序化的操作有关,并且那确实是它所承担的职责的副产品,但你真的可以把它当作unwrap来看待。promise是一个存放未知值的容器,而then的任务就是把这个值从promise中提取出来,把它交给另一个函数:从单子的角度来看就是bind函数。在上面的代码中,我们完全看不出来该值何时可用,或代码执行的顺序是什么,它只表达了某种依赖关系:要想在日志中输出某个值,那你必须先知道这个值是什么。程序执行的顺序是从这些依赖信息中推导出来的。两者的区别其实相当微妙,但随着我们讨论的不断深入,到文章末尾的lazy promises时,这个区别就会变得愈加明显。

    到目前为止,你看到的都是些无足轻重的东西;一些彼此之间几乎没什么互动的小函数。为了让你了解promises为什么比callback更强大,我们来搞点更需要技巧性的把戏。假设我们要写段代码,用fs.stat()取得一堆文件的mtimes属性。如果这是异步的,我们只需要调用paths.map(fs.stat),但既然跟异步函数映射难度较大,所以我们把async模块挖出来用一下。

    var async = require('async'),
        fs    = require('fs');
    
    var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
    
    async.map(paths, fs.stat, function(error, results) {
      // use the results
    });
    

    (哦,我知道fs的函数都有sync版本,但很多其它I/O操作都没有这种待遇。所以,请淡定地坐下来看我把戏法变完。)

    一切都很美好,但是,新需求来了,我们还需要得到file1的size。只要再stat就可以了:

    var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
    
    async.map(paths, fs.stat, function(error, results) {
      // use the results
    });
    
    fs.stat(paths[0], function(error, stat) {
      // use stat.size
    });
    

    需求满足了,但这个跟size有关的任务要等着前面整个列表中的文件都处理完才会开始。如果前面那个文件列表中的任何一项出错了,很不幸,我们根本就不可能得到第一个文件的size。这可就大大地坏了,所以,我们要试试别的办法:把第一个文件从文件列表中拿出来单独处理。

    var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
        file1 = paths.shift();
    
    fs.stat(file1, function(error, stat) {
      // use stat.size
      async.map(paths, fs.stat, function(error, results) {
        results.unshift(stat);
        // use the results
      });
    });
    

    这样也行,但现在我们已经不能把这个程序称为并行化的了:它要用更长的时间,因为在处理完第一个文件之前,文件列表的请求处理得一直等着。之前它们还都是并发运行的。另外我们还不得不处理下数组,以便可以把第一个文件提出来做特别的处理。

    Okay,最后的成功一击。我们知道需要得到所有文件的stats,每次命中一个文件,如果成功,则在第一个文件上做些工作,然后如果整个文件列表都成功了,则要在那个列表上做些工作。带着对问题中这些依赖关系的认识,用async把它表示出来。

    var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
        file1 = paths.shift();
    
    async.parallel([
      function(callback) {
        fs.stat(file1, function(error, stat) {
          // use stat.size
          callback(error, stat);
        });
      },
      function(callback) {
        async.map(paths, fs.stat, callback);
      }
    ], function(error, results) {
      var stats = [results[0]].concat(results[1]);
      // use the stats
    });
    

    这就对了:每次一个文件,所有工作都是并行的,第一个文件的结果跟其他的没关系,而相关任务可以尽早执行。Mission accomplished!

    好吧,实际上并不尽然。这个太丑了,并且当问题变得更加复杂后,这个显然不易于扩展。为了正确解决问题,要考虑很多东西,而且这个设计意图也不显眼,后期维护时很可能会把它破坏掉,后续任务跟如何完成所需工作的策略混杂在一起,而且我们不得不动用一些比较复杂的数组分割操作来应对这个特殊状况。啊哦!

    这些问题的根源都在于我们用控制流作为解决办法的主体,如果用数据间的依赖关系,就不会这样了。我们的思路不是“要运行这个任务,我需要这个数据”,没有把找出最优路径的工作交给运行时,而是明确地向运行时指出哪些应该并行,哪些应该顺行,所以我们得到了一个特别脆弱的解决方案。

    那promises怎么帮你脱离困境?嗯,首先要有能返回promises的文件系统函数,用callback做参数的那套东西不行。但在这里我们不要手工打造一套文件系统函数,通过元编程作个能转换一切函数的东西就行。比如,它应该接受类型为:

    String -> (Error -> Stat -> ()) -> ()
    

    的函数,并返回:

    String -> Promise Stat
    

    下面就是这样一个函数:

    // promisify :: (a -> (Error -> b -> ()) -> ()) -> (a -> Promise b)
    var promisify = function(fn, receiver) {
      return function() {
        var slice   = Array.prototype.slice,
            args    = slice.call(arguments, 0, fn.length - 1),
            promise = new Promise();
    
        args.push(function() {
          var results = slice.call(arguments),
              error   = results.shift();
    
          if (error) promise.reject(error);
          else promise.resolve.apply(promise, results);
        });
    
        fn.apply(receiver, args);
        return promise;
      };
    };
    

    (这不是特别通用,但对我们来说够了.)

    现在我们可以对问题重新建模。我们需要做的全部工作基本就是将一个路径列表映射到一个stats的promises列表上:

    var fs_stat = promisify(fs.stat);
    
    var paths = ['file1.txt', 'file2.txt', 'file3.txt'];
    
    // [String] -> [Promise Stat]
    var statsPromises = paths.map(fs_stat);
    

    这已经是付利息了:在用 async.map()时,在整个列表处理完之前你拿不到任何数据,而用上promises的列表之后,你可以径直挑出第一个文件的stat做些处理:

    statsPromises[0].then(function(stat) { /* use stat.size */ });
    

    所以在用上promise值后,我们已经解决了大部分问题:所有文件的stat都是并发进行的,并且访问所有文件的stat都和其他的无关,可以从数组中直接挑我们想要的任何一个,不止是第一个了。在前面那个方案中,我们必须在代码里明确写明要处理第一个文件,想换文件时改起来不是那么容易,但用promises列表就容易多了。

    谜底还没有完全揭晓,在得到所有的stat结果之后,我们该做什么?在之前的程序中,我们最终得到的是一个Stat对象的列表,而现在我们得到的是一个Promise Stat 对象的列表。我们想等着所有这些promises都被兑现(resolve),然后生出一个包含所有stats的列表。换句话说,我们想把一个promises列表变成一个列表的promise。

    闲言少叙,我们现在就给这个列表加上promise方法,那这个包含promises的列表就会变成一个promise,当它所包含的所有元素都兑现后,它也就兑现了。

    // list :: [Promise a] -> Promise [a]
    var list = function(promises) {
      var listPromise = new Promise();
      for (var k in listPromise) promises[k] = listPromise[k];
    
      var results = [], done = 0;
    
      promises.forEach(function(promise, i) {
        promise.then(function(result) {
          results[i] = result;
          done += 1;
          if (done === promises.length) promises.resolve(results);
        }, function(error) {
          promises.reject(error);
        });
      });
    
      if (promises.length === 0) promises.resolve(results);
      return promises;
    };
    

    (这个函数跟 jQuery.when() 类似, 以一个promises列表为参数,返回一个新的promise,当参数中的所有promises都兑现后,这个新的promise就兑现了.)

    只需把数组打包在promise里,我们就可以等着所有结果出来了:

    list(statsPromises).then(function(stats) { /* use the stats */ });
    

    我们最终的解决方案就被削减成了下面这样:

    var fs_stat = promisify(fs.stat);
    
    var paths = ['file1.txt', 'file2.txt', 'file3.txt'],
        statsPromises = list(paths.map(fs_stat));
    
    statsPromises[0].then(function(stat) {
      // use stat.size
    });
    
    statsPromises.then(function(stats) {
      // use the stats
    });
    

    该方案的这种表示方式看起来要清楚得多了。借助一点通用的粘合剂(我们的promise辅助函数),以及已有的数组方法,我们就能用正确、有效、修改起来非常容易的办法解决这个问题。不需要async模块特制的集合方法,只是让promises和数组两者的思想各自保持独立,然后以非常强大的方式把它们整合到一起。

    特别要注意这个程序是如何避免了跟并行或顺序相关的字眼出现。它只是说我们想做什么,然后说明任务之间的依赖关系是什么样的,其他的事情就交给promise类库去做了。

    实际上,async集合模块中的很多东西都可以用promises列表上的操作轻松代替。前面已经看到map的例子了:

    async.map(inputs, fn, function(error, results) {});
    

    相当于:

    list(inputs.map(promisify(fn))).then(
        function(results) {},
        function(error) {}
    );
    

    async.each()async.map() 实质上是一样的,只不过each()只是要执行效果,不关心返回值。完全可以用map()代替。

    async.mapSeries() (如前所述,包括 async.eachSeries()) 相当于在promises列表上调用 reduce()。也就是说,你拿到输入列表,并用reduce产生一个promise,每个操作都依赖于之前的操作是否成功。我们来看一个例子:基于fs.rmdir()实现 rm -rf 。代码如下:

    var dirs = ['a/b/c', 'a/b', 'a'];
    async.mapSeries(dirs, fs.rmdir, function(error) {});
    

    相当于:

    var dirs     = ['a/b/c', 'a/b', 'a'],
        fs_rmdir = promisify(fs.rmdir);
    
    var rm_rf = dirs.reduce(function(promise, path) {
      return promise.then(function() { return fs_rmdir(path) });
    }, unit());
    
    rm_rf.then(
        function() {},
        function(error) {}
    );
    

    其中的 unit()只是为了产生一个已解决的promise已启动操作链(如果你知道monads,这就是给promises的return 函数):

    // unit :: a -> Promise a
    var unit = function(a) {
      var promise = new Promise();
      promise.resolve(a);
      return promise;
    };
    

    reduce()只是取出路径列表中的每对目录,用promise.then()根据上一步操作是否成功来执行路径删除操作。这样可以处理非空目录:如果上一个promise由于某种错误被rejecte了,操作链就会终止。用值之间的依赖关系限定执行顺序是函数式语言借助monads处理副作用的核心思想。

    最后这个例子的代码比async版本繁琐得多,但不要被它骗了。关键是领会精神,要将彼此不相干的promise值和list操作结合起来组装程序,而不是依赖定制的流程控制库。如您所见,前一种方式写出来的程序更容易理解。

    准确地讲,它之所以容易理解,是因为我们把一部分思考的过程交给机器了。如果用async模块,我们的思考过程是这样的:

    • A.程序中这些任务间的依赖关系是这样的
    • B.因此各操作的顺序必须是这样
    • C.然后我们把B所表达的意思写成代码吧

    用promises依赖图可以跳过步骤B。代码只要表达任务之间的依赖关系,然后让电脑去设定控制流。换种说法,callback用显式的控制流把很多细小的值粘到一起,而promises用显式的值间关系把很多细小的控制流粘到一起。Callback是指令式的,promises是函数式的。

    如果最终没有一个完整的promises应用,并且是体现函数式编程核心思想 laziness的应用,我们对这个话题的讨论就不算完整。Haskell是一门懒语言,也就是说它不会把程序当成从头运行到尾的脚本,而是从定义程序输出的表达式开始,向stdio、数据库中写了什么等等,以此向后推导。它寻找最终表达式的输入所依赖的那些表达式,按图反向探索,直到计算出程序产生输出所需的一切。只有程序为完成任务而需要计算的东西才会计算。

    解决计算机科学问题的最佳解决方案通常都是找到可以对其建模的准确数据结构。Javascript有一个与之非常相似的问题:模块加载。我们只想加载程序真正需要的模块,而且想尽可能高效地完成这个任务。

    在 CommonJS 和 AMD出现之前,我们确实就已经有依赖的概念了,脚本加载库有一大把。大多数的工作方式都跟前面的例子差不多,明确告诉脚本加载器哪些文件可以并行下载,哪些必须按顺序来。基本上都必须写出下载策略,要想做到正确高效,那是相当困难,跟简单描述脚本间的依赖关系,让加载器自己决定顺序比起来简直太坑人了。

    接下来开始介绍LazyPromise的概念。这是一个promise对象,其中会包含一个可能做异步工作的函数。这个函数只在调用promise的then()时才会被调用一次:即只在需要它的结果时才开始计算它。这是通过重写then()实现的,如果工作还没开始,就启动它。

    var Promise = require('rsvp').Promise,
        util    = require('util');
    
    var LazyPromise = function(factory) {
      this._factory = factory;
      this._started = false;
    };
    util.inherits(LazyPromise, Promise);
    
    LazyPromise.prototype.then = function() {
      if (!this._started) {
        this._started = true;
        var self = this;
    
        this._factory(function(error, result) {
          if (error) self.reject(error);
          else self.resolve(result);
        });
      }
      return Promise.prototype.then.apply(this, arguments);
    };
    

    比如下面这个程序,它什么也不做:因为我们根本没要过promise的结果,所以不用干活:

    var delayed = new LazyPromise(function(callback) {
      console.log('Started');
      setTimeout(function() {
        console.log('Done');
        callback(null, 42);
      }, 1000);
    });
    

    但如果加上下面这行,程序就会输出Started,过了一秒后,在输出Done和42:

    delayed.then(console.log);
    

    但既然这个工作只做一次,调用then()会多次输出结构,但并不会每次都执行任务:

    delayed.then(console.log);
    delayed.then(console.log);
    delayed.then(console.log);
    
    // prints:
    // Started
    // -- 1 second delay --
    // Done
    // 42
    // 42
    // 42
    

    用这个非常简单的通用抽象,我们可以随时搭建一个优化模块系统。假定我们要像下面这样创建一堆模块:每个模块都有一个名字,一个依赖模块列表,以及一个传入依赖项,返回模块API的工厂函数。跟AMD的工作方式非常像。

    var A = new Module('A', [], function() {
      return {
        logBase: function(x, y) {
          return Math.log(x) / Math.log(y);
        }
      };
    });
    
    var B = new Module('B', [A], function(a) {
      return {
        doMath: function(x, y) {
          return 'B result is: ' + a.logBase(x, y);
        }
      };
    });
    
    var C = new Module('C', [A], function(a) {
      return {
        doMath: function(x, y) {
          return 'C result is: ' + a.logBase(y, x);
        }
      };
    });
    
    var D = new Module('D', [B, C], function(b, c) {
      return {
        run: function(x, y) {
          console.log(b.doMath(x, y));
          console.log(c.doMath(x, y));
        }
      };
    });
    

    这里出了一个钻石的形状:D依赖于B和C,而它们每个都依赖于A。也就是说我们可以加载A,然后并行加载B和C,两个都到位后加载D。但是,我们希望工具能自己找出这个顺序,而不是由我们自己写出来。

    这很容易实现,我们把模块当作LazyPromise的子类型来建模。它的工厂只要用我们前面那个list promise辅助函数得到依赖项的值,然后再经过一段模拟的加载时间后用那些依赖项构建模块。

    var DELAY = 1000;
    
    var Module = function(name, deps, factory) {
      this._factory = function(callback) {
        list(deps).then(function(apis) {
          console.log('-- module LOAD: ' + name);
          setTimeout(function() {
            console.log('-- module done: ' + name);
            var api = factory.apply(this, apis);
            callback(null, api);
          }, DELAY);
        });
      };
    };
    util.inherits(Module, LazyPromise);
    

    因为 Module 是 LazyPromise, 只是像上面那样定义模块不会加载。我们只在需要用这些模块的时候加载它们:

    D.then(function(d) { d.run(1000, 2) });
    
    // prints:
    // 
    // -- module LOAD: A
    // -- module done: A
    // -- module LOAD: B
    // -- module LOAD: C
    // -- module done: B
    // -- module done: C
    // -- module LOAD: D
    // -- module done: D
    // B result is: 9.965784284662087
    // C result is: 0.10034333188799373
    

    如上所示,最先加载的是A,完成后同时开始下载B和C,在两个都完成后加载D,跟我们想的一样。如果调用C.then(function() {}),那就只会加载A和C;不在依赖关系图中的模块不会加载。

    所以我们几乎没怎么写代码就创建了一个正确的优化模块加载器,只要用lazy promises的图就行了。我们用函数式编程中值间关系的方式代替了显式声明控制流的方式,比我们自己写控制流容易得多。对于任何一个非循环得依赖关系图,这个库都能用来替你优化控制流。

    这就是promises真正强大的地方。它不仅能在语法层面上规避缩进金字塔,还能让你在更高层次上对问题建模,而把底层工作交给工具完成。真的,那应该是我们所有码农对我们的软件提出的要求。如果Node真的想让并发编程更容易,他们应该再好好看看promises。

  • 相关阅读:
    Springboot中enable注解
    java消息队列--ActiveMQ,RabbitMQ入门
    java中的静态和动态代理Proxy
    java源码中的注解
    java中jdbc源码解读
    sql脚本练习
    spring boot中的jave注解学习
    thymeleaf入门和学习
    职场经验:互联网公司有哪些“潜规则”?职场萌新一定要注意了!
    C/C++编程笔记:C语言错误处理方法!如何更好地处理程序的错误?
  • 原文地址:https://www.cnblogs.com/rubylouvre/p/3292745.html
Copyright © 2011-2022 走看看