• Generator(生成器)函数


    一.基础知识

    Generator函数是ES6出现的一种异步操作实现方案。

    异步即代码分两段,但是不是连续执行,第一段执行完后,去执行其他代码,等条件允许,再执行第二段。

    同步即代码连续执行。

    1. Generator函数是什么?

    Generator函数是一种遍历器生成函数;运行后返回一个遍历器对象。

    函数内部可以看作一个状态机;可以通过遍历器的next方法遍历函数内部的各个状态。

    Generator函数不是构造函数!不能使用new命令生成实例。否则报错!

    但是生成的实例是Generator函数的实例对象。

    function* testGen() {
      yield 1;
    }
    const gen = testGen();
    console.log(gen instanceof testGen); //生成的对象是Generator的实例
    console.log(gen.__proto__ === testGen.prototype); // 生成的实例继承原型上的属性和方法
    
    // 运行结果 
    true
    true

    注意:

    function* testGen() {
       yield 1;
       yield 2;
    }
    
    testGen().next(); // {value:1, done:false}
    testGen().next(); // {value:1, done:false}
    // 上面没有连续执行是因为,每次调用testGen方法都生成一个新的遍历器;如果想连续执行;可以将其赋值给一个变量,操作变量

    构造函数本质是通过new命令,返回this对象,即实例对象。

    但是Generator函数不返回this,它返回遍历器。通过直接执行返回。

    此时函数内部的this是函数执行时所在的对象,即window(非严格模式,严格模式下是undefined)

    <script type="module">
      function* testGen() {
        console.log(this);
      }
      const gen = testGen();
      gen.next();
    </script>
      // 运行结果如下(严格模式)
      undefined

    如果想要生成的遍历器对象可以访问this的属性和方法,可以使用call或者apply方法绑定this到其原型对象上。

      function* testGen() {
        this.a = 5;
        return this;
      }
      const gen = testGen.call(testGen.prototype);
      const thisObj = gen.next().value;// gen.next().value === testGen.prototype
      console.log(thisObj.a); // 5

    2. Generator函数声明

    1.作为函数声明

    function* fnName() { // function和函数名称之间有一个*, 通常紧跟function后
      yield 表达式; // 函数内部使用yield命令
      return val; //表示最后一个状态
    }
    const myGenerator = fnName(); //生成遍历器对象;相当于指向内部状态的指针对象

    其中,*是Generator函数的标识;yield(“产生”)是状态生成命令。

    2. Generator函数作为对象的属性声明

    const obj = {
      *testGen() {
        ...
      }
    }
    // 相当于 
    const obj = {
      testGen: function* () {
        ...
      }
    }

    3. Generator函数生成遍历器对象

    调用Generator函数后,会返回一个遍历器对象;但是此时函数内部代码并不执行。

    遍历器对象可以通过自身的next方法访问Generator函数内部的状态。

    yield命令除了表示生成状态,还表示暂停标识,即next方法遇到yield命令即停止运行。

    所以next()方法表示从代码起始或者上一次停止的位置运行到下一个yield或者return或者代码结束。

    next方法返回结果格式如下:

    {value: 值, done: true/false} 
    // value是当次运行的状态,即yield命令后面的表达式的值
    // done表示是否遍历结束

    想继续执行就要继续调用next方法,如果函数内部没有return,则一直运行到代码结束;

    返回结果最后是:

    {value: undefined, done: true}
    //继续调用next方法,会一直返回该值

    如果遇到return value,则return语句即表示遍历结束。

    返回结果是:

    {value: value, done: true}
    //继续调用next方法,返回
    {value: undefined, done: true}
    // 继续调用next,一直返回该值

    示例: 

    function* testGen() {
      yield 1;
      return 2;
    }
    const gen = testGen();
    console.log(gen.next());
    console.log(gen.next());
    console.log(gen.next());
    console.log(gen.next());
    // 运行结果如下
    {value: 1, done: false}
    {value: 2, done: true}
    {value: undefined, done: true}
    {value: undefined, done: true}

    ✅可以遍历遍历器状态的遍历方法

    function* testGen() {
      yield 1;
      yield 2;
      yield 3;
    }
    const gen = testGen();
    /****1. 通过扩展运算符...*******/
    let result1 = [...gen]; 
    console.log(result1)
    /****2. 通过for...of遍历*******/
    let result2 = [];
    for(let item of gen) {
      result2.push(item);
    }
    console.log(result2)
    /****3. 通过数组解构赋值********/
    let [...result3] = gen;
    console.log(result3)
    
    /****4. 通过Array.from********/
    let result4 = Array.from(gen);
    console.log(result4)
    
    // 运行结果如下:
    [1,2,3] // 遍历器已经遍历结束,继续遍历后面会全部是[]
    []
    []
    []
    一个遍历器只能遍历一遍
    function* testGen() {
      yield 1;
      yield 2;
      yield 3;
    }
    const gen1 = testGen();
    /****1. 通过扩展运算符...*******/
    let result1 = [...gen1]; 
    console.log(result1)
    /****2. 通过for...of遍历*******/
    const gen2 = testGen();
    let result2 = [];
    for(let item of gen2) {
      result2.push(item);
    }
    console.log(result2)
    /****3. 通过数组解构赋值********/
    const gen3 = testGen();
    let [...result3] = gen3;
    console.log(result3)
    
    /****4. 通过Array.from********/
    const gen4 = testGen();
    let result4 = Array.from(gen4);
    console.log(result4)
    
    // 运行结果如下:
    [1,2,3]
    [1,2,3]
    [1,2,3]
    [1,2,3]
    四种遍历遍历器的方法

    4. yield表达式

    1. Generator函数中yield表达式可以不存在

    此时Generator函数是一个等待next方法启动的暂缓执行函数。

    function* testGen() {
      console.log('等待执行');
    }
    const gen = testGen();
    setTimeout(() => {
      gen.next(); // 2秒后打印'等待执行'
    }, 2000)

    2. yield表达式最近的函数只能是Generator函数,否则声明时就会报错

    function* testGen() {
      [1,2].forEach(function(item) { //回调函数不是Generator函数;可以改为for循环
        yield item; //Uncaught SyntaxError: Unexpected identifier
      })
    }

    3. yield表达式不是单独使用或者不是在=右侧时,需要将其括起来

    function* testGen() {
      console.log(1 + (yield 1));
    }

    4. yield表达式本身不返回任何值,或者说返回undefined

    function* testGen() {
      let r1 = yield 1;
      let r2 = yield 2;
      console.log(r1, r2);
    }
    let gen = testGen();
    gen.next();
    gen.next();
    gen.next(); //遍历结束
    // 运行结果如下
    undefined undefined

    5. 带参数的next方法

    gen.next(value);

    带参数的next方法,其中的参数表示上一次yield表达式的返回值。

    所以第一次next方法如果有参数,参数无效,因为第一个next从代码起始执行,之前没有yield表达式。

    function* testGen() {
      let r1 = yield 1;
      let r2 = yield 2;
      console.log(r1, r2);
    }
    let gen = testGen();
    gen.next('是啥都没有用');
    gen.next('give1'); //第一个yield返回give1
    gen.next('give2'); //第二个yield返回give2
    // 运行结果如下
    give1 give2

    如果想要第一个next方法的参数起作用,可以将Generator函数包装一下

    function* testGen() {
      let r1 = yield 1;
      let r2 = yield 2;
      console.log(r1, r2);
    }
    function wrapper(fn) {
      let gen = fn();
      gen.next();
      return gen;
    }
    let gen = wrapper(testGen);
    gen.next('give1'); //第一个yield返回give1
    gen.next('give2'); //第二个yield返回give2
    // 运行结果如下
    give1 give2

    上面的示例也说明,Generator函数的执行进度可以保留。

    二.Generator实例方法

    1. Generator.prototype.throw()

    方法的作用从遍历器对象上手动抛出异常。

    1. 在Generator函数外部,从遍历器对象上抛出异常。

    该异常可以被Generator函数内部的try...catch捕获;前提是至少执行一次next方法;

    抛出异常的位置位于遍历器当前状态指针所在的位置。

    function* testGen() {
      try{
        yield;
      } catch(err) {
        console.log('innerErr-->',err);
      }
      yield 1;
    }
    const gen = testGen();
    gen.next(); 
    gen.throw(new Error("gen.throw")); // 在第二个yield位置抛出异常,无法捕获
    console.log('after');
    // 运行结果
    innerErr-->Error: gen.throw
    after
    innerError1
    function* testGen() {
      try{
        yield;
      } catch(err) {
        console.log('innerErr-->',err);
      }
      yield 1;
    }
    const gen = testGen();
    gen.next(); 
    gen.next(); // 比上面的示例多了一个next方法
    gen.throw(new Error("gen.throw")); // 在第二个yield位置抛出异常,无法捕获
    console.log('after');
    // 运行结果
    Uncaught Error: gen.throw //后面的不再执行
    示例2:throw抛出异常位置

    也可以被外部调用该方法的位置的catch捕获。

    function* testGen() {
      yield;
    }
    const gen = testGen();
    gen.next(); 
    try {
      gen.throw(new Error("gen.throw")); 
    } catch (error) {
      console.log('outer-->',error);
    }
    console.log('after');
    // 运行结果
    outer-->Error: gen.throw
    after
    外部捕获

    当内部和外部try...catch同时存在时,错误先被Generator函数内部捕获;

    function* testGen() {
      try {
        yield;
      } catch (error) {
        console.log('inner-->',error)
      }
    }
    const gen = testGen();
    gen.next(); 
    try {
      gen.throw(new Error("gen.throw")); 
    } catch (error) {
      console.log('outer-->',error);
    }
    console.log('after');
    // 运行结果
    inner-->Error: gen.throw
    after
    内外部捕获同时存在,内部捕获

    注意:try...catch相当于一次性错误捕获器,捕获一次错误,后面再有错误需要另外一个捕获器。

    而且catch方法(Generator内部的异常或者外部遍历器对象throw的异常)会自动触发一次next方法

    function* testGen() {
      try {
        yield; // 相当于在此处抛出异常,被捕获直接进入catch, catch执行完后继续执行
        console.log('yield after');// 不执行
      } catch (error) {
        console.log('inner-->',error); //catch方法会自动触发一次next方法
      }
      console.log('inner catch after');
    }
    const gen = testGen();
    gen.next(); 
    try {
      gen.throw(new Error("gen.throw"));  // 被内部捕获
      gen.throw(new Error("gen.throw1")); // 被外部捕获,直接进入catch
      gen.throw(new Error("gen.throw2")); // 不执行
      console.log('gen.throw after'); // 不执行
    } catch (error) {
      console.log('outer-->',error); 
    }
    console.log('after');
    // 运行结果
    inner-->Error:gen.throw
    inner catch after
    outer-->Error:gen.throw1
    after
    View Code

    2. 在Generator函数外部,用全局throw一个异常,只能在外部捕获异常 。

    外部捕获全局throw的错误的catch方法不会触发next方法。

    function* testGen() {
      try {
        yield;
      } catch (error) {
        console.log('inner-->',error);
      }
    }
    const gen = testGen();
    gen.next(); 
    try {
      throw new Error("global throw");  
    } catch (error) {
      console.log('outer-->',error); 
    }
    console.log('after');
    // 运行结果
    outer-->Error:global throw
    after 
    View Code

    3. 在Generator函数内部抛出异常,既可以被内部捕获,也可以被外部捕获

    function* testGen() {
      try {
        throw new Error("内部抛出异常1");
      } catch (error) {
        console.log('inner-->',error);
      }
      throw new Error("内部抛出异常2");
    }
    const gen = testGen();
    try {
      gen.next();
    } catch (error) {
      console.log('outer-->',error);
    }
    // 运行结果如下:
    inner-->Error:内部抛出异常1
    outer-->Error:内部抛出异常2
    View Code

    4. 在Generator函数内部抛出异常,如果只在外部捕获,那么函数内部后续的代码不再执行;

    js引擎认为遍历器已经遍历结束,再调用next方法会返回{value: undefined, done: false}

    function* testGen() {
      throw new Error("内部抛出异常1");
      console.log('after'); //不执行
    }
    const gen = testGen();
    try {
      gen.next();
    } catch (error) {
      console.log('outer-->',error);
    }
    // 运行结果如下:
    outer-->Error:内部抛出异常1
    View Code

    如果在内部捕获,捕获后,catch后的代码会执行到下一个yield或者结束。

    function* testGen() {
      try{
        throw new Error("内部抛出异常1");
      } catch(error) {
        console.log('inner-->',error)
      }
      console.log('after'); //不执行
    }
    const gen = testGen();
    gen.next()
    // 运行结果如下:
    inner-->Error:内部抛出异常1
    after
    View Code

    2. Generator.prototype.return()

    方法返回return给定的值,并终结遍历器的遍历。

    function* testGen() {
      yield 1;
      yield 2;
      yield 3;
    }
    const gen = testGen();
    gen.next(); // {value: 1, done: false}
    gen.return('end'); // {value: 'end', done: true}
    gen.next(); // {value: undefined, done: true}

    如果调用return()方法的位置位于try模块中,并且后面有finally模块,那么调用return后立即执行finally模块。

    此时,如果finally代码块中有yield表达式,return方法相当于next方法, 然后将return语句放到finally代码块最后。

    function* testGen() {
      try {
        console.log('--1--');
        yield 1;
        console.log('--2--');
        yield 2;  
        console.log('--3--');
      } finally {
        console.log('--finally start--');
        yield 4; 
        yield 5;
        console.log('--finally end--')
      }
    }
    const gen = testGen();
    console.log(gen.next()); 
    console.log(gen.return('end')); //{value: 4, done: false}
    console.log(gen.next()); //{value: 5, done: false}
    console.log(gen.next()); //{value: 'end', done: true}
    //运行结果如下
    --1--
    {value:1, done:false}
    --finally start--
    {value: 4, done: false}
    {value: 5, done: false}
    ---finally end--
    {value: 'end', done: true}

    三.yield* 表达式

    1. 语法

    用于在Generator函数内部遍历另一个Generator函数。

    yield* 表明后面的表达式是个Generator函数,相当于在一个Generator函数内部for...of另一个遍历器。

    function* bar() {
      yield 1;
      yield 2;
    }
    function* foo() {
      yield 3;
      yield* bar();
      /*相当于
      for(let item of bar()) {
        yield item;
      }*/
      yield 4;
    }
    const gen = foo();
    console.log([...gen]); //[3,1,2,4]

    yield表达式没有返回值,但是yield*表达式有返回值。返回值的内容是 yield*后面的Generator函数的return的值。

    function* bar() {
      yield 1;
      return 2;
    }
    function* foo() {
      yield 3;
      let result = yield* bar(); // yield*有返回值;如果bar没有return,则返回值默认undefined
      console.log('result-->',result);
      yield 4;
    }
    const gen = foo();
    console.log([...gen]);
    // 运行结果如下:
    result-->2
    [3,1,4] //2是return的值,不能被for...of遍历

    2. 应用

    1. 将多重数组平铺

    // Generator函数的递归实现
    function* flat(arr) {
      for(let item of arr) {
        if (Array.isArray(item)) {
          yield* flat(item); 
        } else {
          yield item;
        }
      }
    }
    let arr = [1,[2,[3,4]]];
    const gen = flat(arr);
    console.log([...gen]);//[1,2,3,4]

    2. 遍历二叉树,遍历方式用Generator函数实现中序遍历如下:

    // 中序遍历--升序--左->根->右
    let inOrder = function* (node) {
      if (node) {
        yield* inOrder(node.left);
        yield node.data;
        yield* inOrder(node.right);
      }
    }
    
    let data = [56,28,78,20,35,68,88,10,25,30,45,80,89];
    const bst = new BST();
    data.forEach(i => bst.insert(i))
    const result = [...bst.inOrder(bst.root)]; //[10, 20, 25, 28, 30, 35, 45, 56, 68, 78, 80, 88, 89]

    四.应用

    1. 给对象添加iterator接口-[Symbol.iterator]

    诸如数组等可遍历对象本质是本身含有[Symbol.iterator]属性,该属性是一个遍历器生成方法。

    使用for...of或者next()或者...进行遍历时,本质上遍历的是[Symbol.iterator]方法。

    而Generator方法本身就是一个遍历器生成方法。

    所以可以通过将Generator函数赋值给目标对象,使其具有iterator接口。

    示例:给不具有iterator接口的普通对象添加iterator接口

    // 普通对象没有iterator接口,所以不能使用for..of
    function* testGen() {
      let r1 = yield 1;
      let r2 = yield 2;
      console.log(r1, r2);
      return 3;
    }
    let obj = {
      a: 1
    }
    obj[Symbol.iterator] = testGen;
    for (let item of obj) { // obj添加[Symbol.iterator]之前不能被遍历
      console.log(item); //item是内部的状态值
    }
    console.log([...obj]); 
    // 运行结果如下
    1
    2
    undefined undefined //for...of
    undefined undefined // ...遍历
    [1,2]
    View Code

    从上面可知:

    for...of不需要手动遍历,会自动遍历生成器内部的所有状态,但是不会遍历done为true的值。

    所以上面3不会被遍历。

    基础语法:break对于for,while来说都是终止之后的循环;continue终止本次循环

    示例: 实现fibonacci数列

    function* fibonacci() {
      let [prev, curr] = [0, 1];
      yield curr;
      while(true) {
        [prev, curr] = [curr, prev+curr]; 
        yield curr;   
      }
    }
    for(let item of fibonacci()) {
      if (item === 8) {
        break;
      }
      console.log(item);
    }
    // 运行结果如下:
    1
    1
    2
    3
    5
    Generator实现fibonacci数列

    示例:模拟Object.entries()方法

    // Object.entries(obj)可以使用for...of遍历
    // 说明该方法返回遍历器;写一个方法覆盖Object上的原有方法
    Object.entries = function* (obj) {
      let objKeys = Object.keys(obj);
      for (let key of objKeys) {
        console.log('---selfdefined---');
        yield [key, obj[key]];
      }
    }
    let obj = {a:1,b:2};
    for(let [key, value] of Object.entries(obj)) {
      console.log([key,value])
    }
    // 运行结果如下:
    ---selfdefined---
    ['a',1]
    ---selfdefined---
    ['b',2]
    Object.entries源码模拟

     2. Generator函数实现异步操作

    yield后是Thunk函数或者Promise对象。

    名词解释: 

    协程(coroutine): 相互协作的线程。几个线程同时运行,但正在运行的只有一个。

    代码的执行权可以在不同的线程之间切换。

    Generator函数可以交出函数的执行权(通过yield暂停)和取回(next方法)。

    Thunk函数: 含有多个参数(其中含有一个回调函数)的函数转为只接受一个回调函数作为参数的函数。

    语法:

    const Thunk = function(fn) {
      return function(...args) {
        return function(callback) {
          fn.call(this, ...args, callback)
        }
      }
    }
    //本质上是将所有的参数分为非callback参数和callback参数

    Thunk函数使Generator函数可以处理异步任务。并且很方便实现自动执行器函数。

    const Thunk = function(fn) {
      return function (...args) {
        return function (callback) {
          return fn.call(this, ...args, callback);
        }
      };
    };
    const readFileThunk = Thunk(fs.readFile);
    function* readAsync() {
      const r1 = yield readFileThunk('/url1'); 
      const r2 = yield readFileThunk('/url2'); 
      const r3 = yield readFileThunk('/url3'); 
    }
    //执行器
    function run(fn) {
      const gen = fn();
      function next(err, data) { //方法可以通过手动执行推导
        const result = gen.next(data);
        if (result.done) return;
        result.value(next); // 上面的异步操作是同类型的操作,自动执行相当于相同的next方法一直回调
      }
      next();
    }
    run(readAsync);
    View Code

    除了自己实现自动执行函数,还可以通过“co”模块实现Generator函数的自动执行。

    前提: Generator函数中yield后面只能是Thunk函数或者Promise对象。

    Thunk函数参照上面自己实现的run方法。

    const co = require('co');
    function* readAsync() {
      const r1 = yield readFileThunk('/url1'); 
      const r2 = yield readFileThunk('/url2'); 
      const r3 = yield readFileThunk('/url3'); 
    }
    // 将Generator函数传入co方法会自动执行
    co(readAsync).then(() => {  //co函数返回一个Promise对象
      console.log('遍历结束执行该语句');
    });

    当yield后面是Promise对象时

    // yield后面是Promise的情况
    function readFilePromise(fileName) {
      return new Promise((resolve, reject) => {
        fs.readFile(fileName, function(err, data) {
          if (err) reject(err);
          resolve(data);
        })
      })
    }
    function* readAsync() {
      const r1 = yield readFilePromise('/url1'); 
      const r2 = yield readFilePromise('/url2'); 
      const r3 = yield readFilePromise('/url3'); 
    }
    //手动实现执行器--推导过程
    const gen = readAsync();
    gen.next().value.then(data => { //可以发现回调函数的内容是类似的
      gen.next(data).value.then(data => {
        gen.next(data);
      })
    })
    //根据上面的示例自己实现执行器
    function run(fn) {
      const gen = fn();
      function next(data) { 
        const result = gen.next(data);
        if (result.done) return result.value; // 如果yield后面是Promise对象,run(fn)的返回值是return的值
        result.then((data) => { //等待异步操作执行完成
          next(data);
        }); 
      }
      next();
    }
    run(readAsync);

    co方法的原理: 1.判断参数是否是Generator 2. 返回一个Promise对象 3. 通过then递归 4. yield后面不是Promise或者Thunk执行Reject

    function co(gen) {
      var ctx = this;
    
      return new Promise(function(resolve, reject) {
        if (typeof gen === 'function') gen = gen.call(ctx);
        if (!gen || typeof gen.next !== 'function') return resolve(gen);
    
        onFulfilled();
        function onFulfilled(res) {
          var ret;
          try {
            ret = gen.next(res);
          } catch (e) {
            return reject(e);
          }
          next(ret);
        }
      });
    }
    function next(ret) {
      if (ret.done) return resolve(ret.value);
      var value = toPromise.call(ctx, ret.value);
      if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
      return onRejected(
        new TypeError(
          'You may only yield a function, promise, generator, array, or object, '
          + 'but the following object was passed: "'
          + String(ret.value)
          + '"'
        )
      );
    }
    co原理

    co模块支持并发的异步操作: 几个异步操作同时执行, 将其放入yield后的数组或者对象中,等全部执行完,才执行下一步。

    // 注意: 要求在co模块中yield才能用[]或者{}
    const co = require('co');
    // 数组
    co(function* gen() {
      const res = yield [ // 返回一个数组
        Promise.resolve(1),
        Promise.resolve(2)
      ];
      console.log(res); // [1, 2]
    }).catch(() => {})
    // 对象
    co(function* gen() {
      const res = yield {
        0: Promise.resolve(1),
        1: Promise.resolve(2)
      }
      console.log(res); // { '0': 1, '1': 2 }
    }).catch(() => {})
    console.log('end');
    // 运行结果如下:
    end
    [1,2]
    {'0':1, '1':2} //说明co方法是异步方法

    co模块并发的应用: 处理Node中Stream事件

    const co = require('co');
    const fs = require('fs');
    
    const txtPath = path.join(__dirname, '1.txt');
    const readStream = fs.createReadStream(txtPath);
    
    let toWordCount = 0;
    function* countWord() {
      while(true) {
        const result = yield Promise.race([
          new Promise((resolve, reject) => readStream.once('data',resolve)),
          new Promise((resolve, reject) => readStream.once('end',resolve)),
          new Promise((resolve, reject) => readStream.once('error',reject)),
        ]);
        if (!result) { // data事件触发的时候result有效<Buffer....>
          break;
        }   
        console.log('result-->',result.toString()); //toString方法将二进制转为字符串
        readStream.removeAllListeners('data'); 
        readStream.removeAllListeners('end'); 
        readStream.removeAllListeners('error'); 
        toWordCount+=(result.toString().match(/to/ig) || []).length;
      }
      console.log('count-->',toWordCount);
    }
    co(countWord);
    // 运行结果如下
    result--> You cannot look back to the long period of our private friendship and political harmony with more affecting recollections than I do. If they are a source of pleasure to you, what aren’t they not to be to me? We cannot be deprived of the happy consciousness of the pure devotion to the public good with Which we discharge the trust committed to us and I indulge a confidence that sufficient evidence will find in its way to another generation to ensure, after we are gone
    count--> 8
  • 相关阅读:
    eclipse设置字体大小
    如何利用服务器下发的Cookie实现基于此Cookie的会话保持
    Docker学习笔记_安装和使用Python
    Docker学习笔记_安装和使用mysql
    Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get http://%2Fvar%2Frun%2Fdocker.sock/v1.38/images/json: dial unix /var/run/docker.sock: conne
    Ubuntu18.04创建新的系统用户
    Docker学习笔记_安装和使用nginx
    Docker安装和使用Tomcat
    虚拟机ubuntu18.04设置静态IP
    我学习参考的网址
  • 原文地址:https://www.cnblogs.com/lyraLee/p/11789402.html
Copyright © 2020-2023  润新知