NodeJS C++ Addons之C++类实例包装与异步操作

本文是对NodeJS C++ Addons原生写法的进一步探索,介绍了利用原生的Node和V8提供的API实现类包装和异步调用的具体做法。在阅读本文之前,如果对NodeJS C++ Addons的基础不熟悉的话,建议先阅读上一篇博客【NodeJS C++ Addons基础】进行了解之后再回来阅读本文。

本文所使用的代码示例可以从该仓库中找到–【cpp-addons】

备注: 本文旨在探究NodeJS C++ Addons的原生写法,了解部分底层知识,所使用的NodeJS版本为8.11.1,由于V8原生的API会发生变动,不同版本的NodeJS的支持情况可能不同。因此不保证代码能兼容所有版本的NodeJS。

一、C++类和对象实例的包装

NodeJS C++插件除了可以向JavaScript提供函数接口之外,还可以将一些C++类或者C++对象实例包装后直接提供给JavaScript使用。举个例子,假设有个用C++实现的类,类名为SomeClass,现在想要在JavaScript中直接使用该类,通过new SomeClass(...)直接创建该类的实例并进行使用。

接下来将使用一个简单的例子来说明如何进行C++类和对象的包装。在这个例子中,将实现一个C++类Accumulator,该类是一个累加器,提供add()getAddTimes()两个方法,add()方法用于将参数累加并返回当前的累加值,getAddTimes()则是返回当前的累加次数。在创建Accumulator实例的时候,可以指定累加开始的初始值。最后,我们期望实现的效果如下,可以在JavaScript中使用这个C++类并创建该类的实例,并且可以调用该类上定义的方法。

// cpp-object-wrap demo
const AccumulatorModule = require('./build/Release/Accumulator')
let acc = new AccumulatorModule.Accumulator(2)
console.log('[ObjectWrapDemo] 2 + 12 = ' + acc.add(12))
console.log('[ObjectWrapDemo] 2 + 12 + 5 = ' + acc.add(5))
console.log('[ObjectWrapDemo] add times: ' + acc.getAddTimes())

在C++中,Accumulator是一个通过class关键字定义的普通类,而在JavaScript中,一个类即为一个JS函数。在C++中,Accumulator的实例是一个普通的C++类实例,在JavaScript中,一个实例即为一个JS对象。JS函数在V8中对应的是一个v8::Function实例,JS对象在V8中对应的是一个v8::Object实例,因此,包装要做的事情,便是将一个C++类包装成一个v8::Function实例,将一个C++实例对象包装成一个v8::Object实例,然后提供给JavaScript使用。下面是该C++插件的实现源码,注释包含了对包装过程的介绍。

#include <node.h>
#include <node_object_wrap.h>

namespace CppObjectWrapDemo {
  using v8::Context;
  using v8::Function;
  using v8::FunctionCallbackInfo;
  using v8::FunctionTemplate;
  using v8::Isolate;
  using v8::Local;
  using v8::Number;
  using v8::Object;
  using v8::Persistent;
  using v8::String;
  using v8::Value;
  using v8::Exception;

  /* 将C++类封装给JS使用,需要继承node::ObjectWrap */
  class Accumulator : public node::ObjectWrap {
    public:
      /* 初始化该类的JS构造函数,并返回JS构造函数 */
      static Local<Function> init (Isolate* isolate) {
        /* 利用函数模板,将一个C++函数包装成JS函数 */
        Local<FunctionTemplate> tpl = FunctionTemplate::New(isolate, AccumulatorJS);
        tpl->SetClassName(String::NewFromUtf8(isolate, "Accumulator"));
        tpl->InstanceTemplate()->SetInternalFieldCount(1);
        /* 类方法定义在构造函数的prototype上 */
        NODE_SET_PROTOTYPE_METHOD(tpl, "add", add);
        NODE_SET_PROTOTYPE_METHOD(tpl, "getAddTimes", getAddTimes);
        /* 获取Accumulator类的JS构造函数 */
        Local<Function> fn = tpl->GetFunction();
        /* JS构造函数句柄存储于constructor上,后续还会使用到 */
        constructor.Reset(isolate, fn);
        return fn;
      }
    private:
      /* 成员变量 */
      static Persistent<Function> constructor;
      double value;
      int addTimes;

      /* 该类的C++构造函数,设置成员变量初始值 */
      explicit Accumulator (double initValue = 0) {
        this->value = initValue;
        this->addTimes = 0;
      }

      /* 该类的JS构造函数,创建该类的对象,并包装成JS对象然后进行返回 */
      static void AccumulatorJS (const FunctionCallbackInfo<Value>& args) {
        Isolate* isolate = args.GetIsolate();
        if (args.IsConstructCall()) {/* 通过 new Accumulator() 创建对象 */
          /* 提取参数数值 */
          double val = args[0]->IsUndefined() ? 0 : args[0]->NumberValue();
          /* 创建该类的实例对象 */
          Accumulator* obj = new Accumulator(val);
          /* 包装该对象 */
          obj->Wrap(args.This());
          /* 返回该对象 */
          args.GetReturnValue().Set(args.This());
        } else {/* 通过直接调用函数 Accumulator() 创建对象,抛出异常 */
          isolate->ThrowException(Exception::TypeError(
            String::NewFromUtf8(isolate, "Should use the new operator to create an instance.")
          ));
        }
      }

      /* 该类的成员方法,增加value的值 */
      static void add (const FunctionCallbackInfo<Value>& args) {
        Isolate* isolate = args.GetIsolate();
        /* 将被包装的JS对象还原为C++对象 */
        Accumulator* obj = node::ObjectWrap::Unwrap<Accumulator>(args.Holder());
        /* 访问C++对象上的成员变量进行操作 */
        obj->value += args[0]->NumberValue();
        obj->addTimes += 1;
        args.GetReturnValue().Set(Number::New(isolate, obj->value));
      }

      /* 该类的成员方法,获取累加次数 */
      static void getAddTimes (const FunctionCallbackInfo<Value>& args) {
        Isolate* isolate = args.GetIsolate();
        /* 将被包装的JS对象还原为C++对象 */
        Accumulator* obj = node::ObjectWrap::Unwrap<Accumulator>(args.Holder());
        args.GetReturnValue().Set(Number::New(isolate, obj->addTimes));
      }
  };

  Persistent<Function> Accumulator::constructor;

  void init (Local<Object> exports) {
    Isolate* isolate = exports->GetIsolate();
    /* 初始化Accumulator类的JS构造函数 */
    Local<Function> _Accumulator = Accumulator::init(isolate);
    /* 将Accumulator类的JS构造函数暴露给JS使用 */
    /* 这里不能使用NODE_SET_METHOD,因为NODE_SET_METHOD是暴露一个C++函数给JS使用 */
    /* NODE_SET_METHOD(exports, "Accumulator", _Accumulator); */
    /* 此处是暴露一个JS函数,它在C++里面表示为一个Function对象,不是一个C++函数 */
    /* 要通过设置属性的方法将其挂到exports上 */
    exports->Set(String::NewFromUtf8(isolate, "Accumulator"), _Accumulator);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, init)
}

进行C++类包装,需要引入头文件<node_object_wrap.h>,被包装的类需要继承node::ObjectWrap类。被包装的C++类除了拥有自己的C++构造函数之外,还需要有一个JS构造函数,C++构造函数在创建该类的C++实例的时候会被调用,而JS构造函数则是在JavaScript中创建该类的对象的时候会被调用。在JS构造函数中写好当JS创建C++类实例的时候要进行的操作,然后将该JS构造函数包装成一个v8::Function提供给JavaScript使用。以上面代码为例,AccumulatorJS是该类的JS构造函数,在AccumulatorJS执行的时候,会创建一个C++类实例,然后将该实例包装变成一个v8::Object之后返回。AccumulatorJS本身是一个C++函数,为了能在JS中调用到它,需要先在Accumulator::init方法利用FunctionTemplate根据AccumulatorJS定制出一个JS函数。

在JS中,使用函数来实现类,类的成员方法一般定义在构造函数的原型(prototype)上。在Accumulator::init方法中,使用NODE_SET_PROTOTYPE_METHOD来将C++类的成员方法挂到JS构造函数的原型上。这样做以后,在JS中创建出该类的实例后,便可以调用到这些成员方法了。

该类的成员方法add()getAddTimes()会在JS中被调用,所以在调用时也会得到const FunctionCallbackInfo<Value>& args参数。可以通过args参数来进行获取JS传递过来的参数信息以及设置返回值等操作。由于在这两个成员方法中,需要访问到类实例上成员变量,因此需要先将发起调用的JS对象解包装,还原成C++实例。解包装时候会用到node::ObjectWrap::Unwrap<Accumulator>(args.Holder())args.Holder()可以获取到发起调用的JS对象实例。

实现了类和对象实例的包装以后,便可以将相关接口暴露给JS使用了。跟其他C++插件模块一样,也是通过module.exports属性来暴露相关接口。这里需要注意的点在代码注释中也有说明,由于我们暴露给JS使用的东西是一个JS构造函数,而不是普通的C++函数,所以不能使用NODE_SET_METHOD来设置暴露的内容,而是直接通过exports对象的Set()方法来进行设置。

完成之后将下面内容写入binding.gyp,进行编译构建,得到.node文件后便可以利用前面展示的JS代码去进行测试了。JS代码运行后的输出结果如图所示,可以验证通过类和对象实例的包装,JS能够直接使用C++的类和实例。

{
  "targets": [{
    "target_name": "accumulator",
    "sources": ["accumulator.cc"]
  }]
}

C++类和对象包装实例

二、C++类实例工厂

前面展示了如何将一个C++类进行包装然后暴露给JavaScript使用。在使用过程中,JavaScript能够直接通过new操作符来创建该类的实例。现在,我们希望C++模块能够提供一个工厂函数,JavaScript调用该工厂函数之后也可以获取到该C++类的实例,而不需要通过new操作符来创建实例。继续在前面的Accumulator例子上进行改进,以探索实现C++向JavaScript提供类实例工厂函数的方法。

Accumulator增加getInstance()方法,通过Accumulator::getInstance()方法,可以在C++代码里面主动调用Accumulator类的JS构造函数,从而创建一个类实例并将类实例包装成JS对象,达到与在JS中使用new操作符时同样的目的。下面是代码展示,部分操作在注释中有进行说明。

#include <node.h>
#include <node_object_wrap.h>

namespace CppObjectWrapDemo {
  using v8::Context;
  using v8::Function;
  using v8::FunctionCallbackInfo;
  using v8::FunctionTemplate;
  using v8::Isolate;
  using v8::Local;
  using v8::Number;
  using v8::Object;
  using v8::Persistent;
  using v8::String;
  using v8::Value;
  using v8::Exception;

  /* 将C++类封装给JS使用,需要继承node::ObjectWrap */
  class Accumulator : public node::ObjectWrap {
    public:
      /* 初始化该类的JS构造函数,并返回JS构造函数 */
      static Local<Function> init (Isolate* isolate) {
        /* 利用函数模板,将一个C++函数包装成JS函数 */
        Local<FunctionTemplate> tpl = FunctionTemplate::New(isolate, AccumulatorJS);
        tpl->SetClassName(String::NewFromUtf8(isolate, "Accumulator"));
        tpl->InstanceTemplate()->SetInternalFieldCount(1);
        /* 类方法定义在构造函数的prototype上 */
        NODE_SET_PROTOTYPE_METHOD(tpl, "add", add);
        NODE_SET_PROTOTYPE_METHOD(tpl, "getAddTimes", getAddTimes);
        /* 获取Accumulator类的JS构造函数 */
        Local<Function> fn = tpl->GetFunction();
        /* JS构造函数句柄存储于constructor上,后续还会使用到 */
        constructor.Reset(isolate, fn);
        return fn;
      }

      /* 获取该类实例的工厂函数 */
      static void getInstance (const FunctionCallbackInfo<Value>& args) {
        Isolate* isolate = args.GetIsolate();
        Local<Value> argv[1] = { args[0] };
        /* 获取当前上下文 */
        Local<Context> context = isolate->GetCurrentContext();
        /* 生成JS构造函数 */
        Local<Function> _constructor = Local<Function>::New(isolate, constructor);
        /* 创建实例 */
        Local<Object> obj = _constructor->NewInstance(context, 1, argv).ToLocalChecked();
        /* 返回实例 */
        args.GetReturnValue().Set(obj);
      }
    private:
      /* 成员变量 */
      static Persistent<Function> constructor;
      double value;
      int addTimes;

      /* 该类的C++构造函数,设置成员变量初始值 */
      explicit Accumulator (double initValue = 0) {
        this->value = initValue;
        this->addTimes = 0;
      }

      /* 该类的JS构造函数,创建该类的对象,并包装成JS对象然后进行返回 */
      static void AccumulatorJS (const FunctionCallbackInfo<Value>& args) {
        Isolate* isolate = args.GetIsolate();
        if (args.IsConstructCall()) {/* 通过 new Accumulator() 创建对象 */
          /* 提取参数数值 */
          double val = args[0]->IsUndefined() ? 0 : args[0]->NumberValue();
          /* 创建该类的实例对象 */
          Accumulator* obj = new Accumulator(val);
          /* 包装该对象 */
          obj->Wrap(args.This());
          /* 返回该对象 */
          args.GetReturnValue().Set(args.This());
        } else {/* 通过直接调用函数 Accumulator() 创建对象,抛出异常 */
          isolate->ThrowException(Exception::TypeError(
            String::NewFromUtf8(isolate, "Should use the new operator to create an instance.")
          ));
        }
      }

      /* 该类的成员方法,增加value的值 */
      static void add (const FunctionCallbackInfo<Value>& args) {
        Isolate* isolate = args.GetIsolate();
        /* 将被包装的JS对象还原为C++对象 */
        Accumulator* obj = node::ObjectWrap::Unwrap<Accumulator>(args.Holder());
        /* 访问C++对象上的成员变量进行操作 */
        obj->value += args[0]->NumberValue();
        obj->addTimes += 1;
        args.GetReturnValue().Set(Number::New(isolate, obj->value));
      }

      /* 该类的成员方法,获取累加次数 */
      static void getAddTimes (const FunctionCallbackInfo<Value>& args) {
        Isolate* isolate = args.GetIsolate();
        /* 将被包装的JS对象还原为C++对象 */
        Accumulator* obj = node::ObjectWrap::Unwrap<Accumulator>(args.Holder());
        args.GetReturnValue().Set(Number::New(isolate, obj->addTimes));
      }
  };

  Persistent<Function> Accumulator::constructor;

  void getAccumulatorInstance(const FunctionCallbackInfo<Value>& args) {
    Accumulator::getInstance(args);
  }

  void init (Local<Object> exports) {
    Isolate* isolate = exports->GetIsolate();
    /* 初始化Accumulator类的JS构造函数 */
    Local<Function> _Accumulator = Accumulator::init(isolate);
    /* 将Accumulator类的JS构造函数暴露给JS使用 */
    /* 这里不能使用NODE_SET_METHOD,因为NODE_SET_METHOD是暴露一个C++函数给JS使用 */
    /* NODE_SET_METHOD(exports, "Accumulator", _Accumulator); */
    /* 此处是暴露一个JS函数,它在C++里面表示为一个Function对象,不是一个C++函数 */
    /* 要通过设置属性的方法将其挂到exports上 */
    exports->Set(String::NewFromUtf8(isolate, "Accumulator"), _Accumulator);
    /* 将获取实例的工厂方法暴露给JS */
    NODE_SET_METHOD(exports, "getAccumulatorInstance", getAccumulatorInstance);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, init)
}

Accumulator::getInstance()方法中,利用_constructorNewInstance()方法可以创建一个实例,执行该方法的作用相当于JS中使用new操作符时候的作用,也会调用对应的JS构造函数,从而创建出该类的实例并包装成一个JS对象进行返回。

Accumulator::getInstance()方法在C++中调用,为了将其暴露给JavaScript使用,在最后还需要创建一个getAccumulatorInstance()函数,在这个函数里面完成对Accumulator::getInstance()的调用。将getAccumulatorInstance()挂到exports属性上后便可提供给JavaScript使用了。

可使用下面的JS代码对C++模块进行检验,代码输出如图所示。JavaScript能够调用C++模块提供的工厂函数,从而获得一个C++类的实例并且可以对该实例进行使用,调用该类所定义的成员方法。

// cpp-object-wrap-factory demo
const AccumulatorModule = require('./build/Release/Accumulator')
let acc2 = AccumulatorModule.getAccumulatorInstance(3)
console.log('[ObjectWrapFactoryDemo] 3 + 16 = ' + acc2.add(16))
console.log('[ObjectWrapFactoryDemo] 3 + 16 + 7 = ' + acc2.add(7))
console.log('[ObjectWrapFactoryDemo] 3 + 16 + 7 + 4 = ' + acc2.add(4))
console.log('[ObjectWrapFactoryDemo] add times: ' + acc2.getAddTimes())

C++类实例工厂实例

三、异步操作

NodeJS C++插件可以提供接口给JavaScript使用,在上一篇博客【NodeJS C++ Addons基础】也已经给出不少示例。不过,在之前的示例中,实现的都是同步调用。按照之前的做法,JavaScript调用C++插件模块提供的接口后,会阻塞JavaScript代码的执行。同步调用带来的阻塞不符合JavaScript异步的特点,在遇到复杂耗时的任务时,这种阻塞更是严重影响应用的性能。为了解决这个问题,就需要实现异步调用,使得C++插件模块的接口在被调用后不会阻塞JavaScript的执行。

实现异步操作需要用到libuv这个库,libuv是一个跨平台的抽象库,它实现了Node.js的事件循环、工作线程、以及平台所有的的异步操作。利用libuv可以很方便地让C++插件模块的接口异步化。接下来将对上一篇博客【NodeJS C++ Addons基础】中的使用的累加求和的例子进行改进,实现异步调用。下面是改进后的代码,在注释中会对部分操作进行阐述。

#include <uv.h>
#include <node.h>
#include <vector>

namespace AsyncDemo {
  using v8::Function;
  using v8::FunctionCallbackInfo;
  using v8::Isolate;
  using v8::Local;
  using v8::Number;
  using v8::Object;
  using v8::Value;
  using v8::Null;
  using v8::Exception;
  using v8::String;
  using v8::Undefined;
  using v8::Persistent;
  using v8::HandleScope;

  /* 存放数据供子线程使用的结构体 */
  struct Data {
    /* 回调函数 */
    Persistent<Function> callback;
    /* 求和参数 */
    std::vector<double> args;
    /* 求和结果 */
    double result;
  };

  /* 子线程执行的代码 */
  void calculate (uv_work_t* req) {
    Data* data = static_cast<Data*>(req->data);
    /* 遍历参数进行求和 */
    data->result = 0.0;
    for (int i = 0; i < data->args.size(); ++i) {
      data->result += data->args[i];
    }
  }

  /* 子线程结束后执行的代码 */
  void calculateComplete (uv_work_t* req) {
    Data* data = static_cast<Data*>(req->data);
    Isolate* isolate = Isolate::GetCurrent();
    /* 必须创建一个HandleScope,否则后面无法创建句柄 */
    HandleScope handleScope(isolate);
    /* 将求和结果转换为一个JS Number */
    Local<Value> argv[1] = { Number::New(isolate, data->result) };
    /* 通过回调函数返回求和结果 */
    Local<Function> cb = Local<Function>::New(isolate, data->callback);
    cb->Call(Null(isolate), 1, argv);
    /* 回调完成后清除资源 */
    data->callback.Reset();
    delete data;
    delete req;
  }

  void accumulateAsync (const FunctionCallbackInfo<Value>& args) {
    Isolate* isolate = args.GetIsolate();
    /* 参数不合理异常 */
    if (args.Length() < 1) {
      isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, "Arguments Number Error.")
      ));
      return;
    }

    /* 没有回调函数 */
    if (!args[args.Length() - 1]->IsFunction()) {
      isolate->ThrowException(Exception::TypeError(
        String::NewFromUtf8(isolate, "No Callback Error.")
      ));
      return;
    }

    /* 提取通过参数传递的回调函数 */
    Local<Function> callback = Local<Function>::Cast(args[args.Length() - 1]);
    /* 创建Data结构体存储数据 */
    Data* data = new Data();
    /* 存储回调函数 */
    data->callback.Reset(isolate, callback);
    /* 提取参数并存储到data */
    for (int i = 0; i < args.Length() - 1; ++i) {
      /* 如果参数不是数字,向js抛出异常 */
      if (!args[i]->IsNumber()) {
        isolate->ThrowException(Exception::TypeError(
          String::NewFromUtf8(isolate, "Arguments Type Error.")
        ));
        return;
      } else {
        data->args.push_back(args[i]->NumberValue());
      }
    }

    /* 启动工作线程进行求和计算 */
    uv_work_t *req = new uv_work_t();
    req->data = data;
    uv_queue_work(
      uv_default_loop(),
      req,
      (uv_work_cb)calculate,
      (uv_after_work_cb)calculateComplete
    );

    /* 本函数直接返回,无需等待线程计算完成 */
    args.GetReturnValue().Set(Undefined(isolate));
  }

  void init (Local<Object> exports) {
    NODE_SET_METHOD(exports, "accumulateAsync", accumulateAsync);
  }

  NODE_MODULE(NODE_GYP_MODULE_NAME, init)
}

使用libuv这个库,需要包含头文件<uv.h>。利用uv_queue_work(uv_loop_t* loop, uv_work_t* req, uv_work_cb work_cb, uv_after_work_cb after_work_cb)这个方法将任务发送到线程池,指明子线程的执行函数work_cb和回调函数after_work_cb后,子线程会执行work_cb,并且在线程执行完毕后回调after_work_cbuv_work_t* req用来存放发起线程调用的请求信息,在执行函数work_cb和回调函数after_work_cb中可以通过req来获取主线程想要子线程处理的数据。在内存上,线程不共享栈,但是共享堆,因此,主线程可以通过一个结构体来包装数据然后在堆上创建该结构体的实例,子线程通过指针去堆上获取该结构体实例便可以获取到主线程想要传递的数据,也可以通过这个实例将数据返回给主线程。

以上面代码为例,在accumulateAsync()方法中,完成参数检查和提取以后,便可以开启一个子线程去对数据进行操作计算,不必等待子线程结束直接返回。JavaScript调用accumulateAsync()方法之后,实际上是开启了一个子线程去完成求和过程,accumulateAsync()方法调用后马上返回,因此不会阻塞到后续的JavaScript代码的执行。子线程的执行函数calculate会完成求和计算过程,并将求和结果存放到Data结构体的一个实例中,子线程执行完毕后会唤醒主线程,运行回调函数calculateComplete,在calculateComplete中完成对JavaScript回调函数的调用,将求和结果通过回调函数回传给JavaScript。

结构体Data中,存储JS回调函数的句柄时需要使用Persistent句柄,Persistent句柄需要主动执行代码进行释放,与之相对的Local句柄会在函数调用结束后句柄作用域消失时被释放,失去句柄引用的变量会被V8的垃圾清理机制自动清除。由于子线程执行时主线程的accumulateAsync()方法已经结束返回了,为了让回调函数不会清理,在子线程结束后能够顺利完成回调JavaScript,这里必须使用Persistent句柄。

将以下内容写入文件binding.gyp,完成编译构建得到.node文件。

{
  "targets": [{
    "target_name": "accumulate_async",
    "sources": ["accumulate_async.cc"]
  }]
}

使用以下JS代码进行测试,输出结果如图所示,可以看到先输出的语句是Hi~,由于实现了异步调用,求和过程不会阻塞JavaScript代码的执行,因此在调用accumulateAsync()之后,跟在后面的console.log('[AsyncDemo] Hi~')不会被阻塞,正常执行。

// async-demo
const AccumulateAsync = require('./build/Release/accumulate_async')
AccumulateAsync.accumulateAsync(1, 3, 4, 7, (sum) => {
  console.log('[AsyncDemo] 1 + 3 + 4 + 7 = ' + sum)
})
console.log('[AsyncDemo] Hi~')

异步调用实例

四、小结

实现C++类和实例的包装,让JavaScript能够直接使用到C++类和实例,这个过程的操作虽然繁琐了一点,但也不是太难,<node_object_wrap.h>中提供的API已经可以很方便地实现这个功能。实现C++插件接口的异步化,让JavaScript能够进行异步调用而不被阻塞,需要用到多线程的知识。在NodeJS官方文档中对如何使用libuv进行异步操作并没有相关例子展示,所以在写前面的例子的时候也比较折腾。libuv的API文档比较晦涩,幸好网上还有另一份介绍文档【An Introduction to libuv】,这份文档中除了对一些概念和API进行介绍之外,也会有给出代码示例,对初学者来说比较友好。

猜你喜欢

转载自blog.csdn.net/hongchh/article/details/80007910
今日推荐