kurento代码分析(一)C++与C的交互 kurento代码分析(一)C++与C的交互

kurento代码分析(一)C++与C的交互

 简单阅读了下kurento的代码,因为自身也是小白,许多地方也是一知半解的。它的代码不容易理清逻辑,它采用gstreamer的流媒体处理框架,信令处理部分主要由c++负责,而媒体处理部分则由c层的gst-plugins完成。gst-plugins本身基于GObject和gstreamer,GObject采用C语言来实现面向对象编程思想。
 关于GObject实现的面向对象,可以参考[1]。[2]是作者的练手代码,里面有对象的创建g_object_new,信号的创建g_signal_new,信号与回调的关联g_signal_connect,信号的发射g_signal_emit_by_name。它的信号机制应该和QT中的信号槽机制类似,可以参看[3]。当然,kurento的c++代码里面也大量使用信号槽的机制,它采用的库是libsigc++。
 回到[2]的代码,看看它的对象创建部分:

    media = g_object_new(TYPE_MEDIA,
                         "inventory-id", 42,
                         "orig-package", FALSE,
                         NULL);
  • 1
  • 2
  • 3
  • 4

TYPE_MEDIA是对象的标识,GObject可以根据这个标识,可以对对象进行实例化,简单地说,就是给结构体分配内存,注册结构体相关的处理函数。

#define TYPE_MEDIA           (media_get_type())
GType media_get_type (void) {
    static GType type = 0;
    if (type) return type;

    static const GTypeInfo media_info = {
        sizeof (MediaClass),                /* class structure size */
        NULL,                               /* base class initializer */
        NULL,                               /* base class finalizer */
        (GClassInitFunc) media_class_init,  /* class initializer */
        NULL,                               /* class finalizer */
        NULL,                               /* class data */
        sizeof (Media),                     /* instance struct size */
        0,                                  /* preallocated instances */
        NULL,                               /* instance initializer */
        NULL                                /* function table */
    };
    type = g_type_register_static(
        G_TYPE_OBJECT, /* parent class */
        "Media",       /* type name */
        &media_info,   /* GtypeInfo struct */
        0);            /* flags */
    const GInterfaceInfo cleanable_info = {media_cleanable_init, NULL, NULL};
    g_type_add_interface_static(type, TYPE_CLEANABLE, &cleanable_info);
    return type;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

 GObject中将对象和对象方法进行抽离。好处就是,结构体里面不用携带每一个函数指针,而可以通过一个指针指向所有的函数表,就是模拟了c++中虚函数表的概念,在大量对象创建中,可以节省内存占用。

struct _MediaClass {
  GObjectClass parent_class;
  void (*unpacked)  (Media *media);
  void (*throw_out) (Media *media, gboolean permanent);
};
struct _Media {
  GObject parent_instance;
  guint inv_nr;
  GString *location;
  GString *title;
  gboolean orig_package;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

 但是阅读kurento代码,要是按照先前说的,g_object_new(TYPE_XXX,…)是很少找到的,似乎没有函数调用,就让人怀疑,它的c层对象到底是怎么创建的。而且可以找到很多XXX_get_type只有头文件里有定义,却在c文件里找不到实现。例如这一个:

GType kms_webrtc_endpoint_get_type (void);
  • 1

 这个疑惑就需要参考[5],利用到了GObject中的一个宏定义G_DEFINE_TYPE。

#define kms_webrtc_endpoint_parent_class parent_class
G_DEFINE_TYPE (KmsWebrtcEndpoint, kms_webrtc_endpoint,
    KMS_TYPE_BASE_RTP_ENDPOINT);
  • 1
  • 2
  • 3

 仔细看下kmswebrtcendpoint.c中的函数实现,会发现所有的函数都是 kms_webrtc_endpoint打头的。G_DEFINE_TYPE这个宏帮助实现了kms_webrtc_endpoint_get_type这个函数。
 疑惑依然没有解决,c对象是怎么创建的。kurento使用了gstreamer中的一个奇技淫巧,就是它的plugin动态加载机制。
 可以看下,kms-core/src/gst-plugins目录下的的c文件,几乎每一个文件都有这个宏 #define PLUGIN_NAME “xxxxxxx”,例如这个:

#define PLUGIN_NAME "webrtcendpoint"
//对应的还有这个函数
gboolean
kms_webrtc_endpoint_plugin_init (GstPlugin * plugin)
{
  return gst_element_register (plugin, PLUGIN_NAME, GST_RANK_NONE,
      KMS_TYPE_WEBRTC_ENDPOINT);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

 所以在C++代码里,看不到g_object_new这个函数的调用,但是有大量的gst_element_factory_make函数调用。例如kms-elements/src/server/implementation/objects/WebRtcEndpointImpl.cpp中

#define FACTORY_NAME "webrtcendpoint"
WebRtcEndpointImpl::WebRtcEndpointImpl (const boost::property_tree::ptree &conf,
                                        std::shared_ptr<MediaPipeline>
                                        mediaPipeline, bool recvonly,
                                        bool sendonly, bool useDataChannels,
                                        std::shared_ptr<CertificateKeyType> certificateKeyType) :
  BaseRtpEndpointImpl (conf,
                       std::dynamic_pointer_cast<MediaObjectImpl>
                       (mediaPipeline), FACTORY_NAME)
{
  if (recvonly) {
    g_object_set (element, "offer-dir", GST_SDP_DIRECTION_RECVONLY, NULL);
  }

  if (sendonly) {
    g_object_set (element, "offer-dir", GST_SDP_DIRECTION_SENDONLY, NULL);
  }

  if (useDataChannels) {
    g_object_set (element, "use-data-channels", TRUE, NULL);
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

 BaseRtpEndpointImpl这个对象最终继承自MediaElementImpl,这个类会在其构造函数里创建GElement对象,这个对象是属于C层的。

MediaElementImpl::MediaElementImpl (const boost::property_tree::ptree &config,
                                    std::shared_ptr<MediaObjectImpl> parent,
                                    const std::string &factoryName) : MediaObjectImpl (config, parent)
{
element = gst_element_factory_make(factoryName.c_str(), nullptr);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

 在这个例子下,最终调用的就是”webrtcendpoint”这个plugin,创建 KmsWebrtcEndpoint这个C层的对象。
 创建完成后,就可以对这个element设置属性,例如

 g_object_set (element, "offer-dir", GST_SDP_DIRECTION_SENDONLY, NULL);
  • 1

 这个属性,在kms-core/src/gst-plugins/commons/kmsbasertpendpoint.c中有定义,可以全局搜索下”offer-dir”:

  g_object_class_install_property (object_class, PROP_OFFER_DIR,
      g_param_spec_enum ("offer-dir", "Offer direction", "Offer direction",
          KMS_TYPE_SDP_DIRECTION, DEFAULT_OFFER_DIR,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
static void
kms_base_rtp_endpoint_set_property (GObject * object, guint property_id,
    const GValue * value, GParamSpec * pspec)
{
    case PROP_OFFER_DIR:
      self->priv->offer_dir = g_value_get_enum (value);//对这个参数进行配置
      break;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

 同c层的交互,是通过信号来完成的,例如:

void
WebRtcEndpointImpl::gatherCandidates ()
{
  gboolean ret;

  g_signal_emit_by_name (element, "gather-candidates", this->sessId.c_str (),
                         &ret);

  if (!ret) {
    throw KurentoException (ICE_GATHER_CANDIDATES_ERROR,
                            "Error gathering candidates");
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

 c层接收到”gather-candidates”信号后,会调用相应的函数进行处理。G_STRUCT_OFFSET (KmsWebrtcEndpointClass, gather_candidates),获取的是回调函数在结构体中的地址偏移信息,也就是调用KmsWebrtcEndpointClass中的 gather_candidates这个函数进行处理。

  kms_webrtc_endpoint_signals[SIGNAL_GATHER_CANDIDATES] =
      g_signal_new ("gather-candidates",
      G_TYPE_FROM_CLASS (klass),
      G_SIGNAL_ACTION | G_SIGNAL_RUN_LAST,
      G_STRUCT_OFFSET (KmsWebrtcEndpointClass, gather_candidates), NULL, NULL,
      __kms_webrtc_marshal_BOOLEAN__STRING, G_TYPE_BOOLEAN, 1, G_TYPE_STRING);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

 关于插件的动态加载机制,可以参考[7]。在kms-core.c中有这样一个宏GST_PLUGIN_DEFINE。可以在kurento_init中看看都加载了那些插件。kurento_init应该是gstreamer查找到动态库路径后会执行的函数。

static gboolean
kurento_init (GstPlugin * kurento)
{
  if (!kms_agnostic_bin2_plugin_init (kurento))
    return FALSE;

  if (!kms_agnostic_bin3_plugin_init (kurento))
    return FALSE;

  if (!kms_filter_element_plugin_init (kurento))
    return FALSE;

  if (!kms_hub_port_plugin_init (kurento))
    return FALSE;

  if (!kms_audio_mixer_plugin_init (kurento))
    return FALSE;

  if (!kms_audio_mixer_bin_plugin_init (kurento))
    return FALSE;

  if (!kms_bitrate_filter_plugin_init (kurento))
    return FALSE;

  if (!kms_buffer_injector_plugin_init (kurento))
    return FALSE;

  if (!kms_pass_through_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_src_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_sink_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_duplex_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_sdp_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_rtp_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_uri_plugin_init (kurento))
    return FALSE;

  return TRUE;
}

GST_PLUGIN_DEFINE (GST_VERSION_MAJOR,
    GST_VERSION_MINOR,
    kmscore,
    "Kurento core",
    kurento_init, VERSION, GST_LICENSE_UNKNOWN, "Kurento",
    "http://kurento.com/")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57

 在kms-elements中这个宏GST_PLUGIN_DEFINE出现多次,读者可以全局搜索一下。
 总结,kurento的这种松耦合的代码处理逻辑,增加了学习难度。至于其逻辑处理部分,我还没有看。学习kurento,需要学习一些基础知识,例如GObject和GStreamer。kurento在编译中会自动生成一些代码,在各个文件夹下的generated-cpp,要是没有这个文件,代码读起来逻辑不完整,找不到线索。我编译后的代码,已上传[8]。


 简单阅读了下kurento的代码,因为自身也是小白,许多地方也是一知半解的。它的代码不容易理清逻辑,它采用gstreamer的流媒体处理框架,信令处理部分主要由c++负责,而媒体处理部分则由c层的gst-plugins完成。gst-plugins本身基于GObject和gstreamer,GObject采用C语言来实现面向对象编程思想。
 关于GObject实现的面向对象,可以参考[1]。[2]是作者的练手代码,里面有对象的创建g_object_new,信号的创建g_signal_new,信号与回调的关联g_signal_connect,信号的发射g_signal_emit_by_name。它的信号机制应该和QT中的信号槽机制类似,可以参看[3]。当然,kurento的c++代码里面也大量使用信号槽的机制,它采用的库是libsigc++。
 回到[2]的代码,看看它的对象创建部分:

    media = g_object_new(TYPE_MEDIA,
                         "inventory-id", 42,
                         "orig-package", FALSE,
                         NULL);
  • 1
  • 2
  • 3
  • 4

TYPE_MEDIA是对象的标识,GObject可以根据这个标识,可以对对象进行实例化,简单地说,就是给结构体分配内存,注册结构体相关的处理函数。

#define TYPE_MEDIA           (media_get_type())
GType media_get_type (void) {
    static GType type = 0;
    if (type) return type;

    static const GTypeInfo media_info = {
        sizeof (MediaClass),                /* class structure size */
        NULL,                               /* base class initializer */
        NULL,                               /* base class finalizer */
        (GClassInitFunc) media_class_init,  /* class initializer */
        NULL,                               /* class finalizer */
        NULL,                               /* class data */
        sizeof (Media),                     /* instance struct size */
        0,                                  /* preallocated instances */
        NULL,                               /* instance initializer */
        NULL                                /* function table */
    };
    type = g_type_register_static(
        G_TYPE_OBJECT, /* parent class */
        "Media",       /* type name */
        &media_info,   /* GtypeInfo struct */
        0);            /* flags */
    const GInterfaceInfo cleanable_info = {media_cleanable_init, NULL, NULL};
    g_type_add_interface_static(type, TYPE_CLEANABLE, &cleanable_info);
    return type;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26

 GObject中将对象和对象方法进行抽离。好处就是,结构体里面不用携带每一个函数指针,而可以通过一个指针指向所有的函数表,就是模拟了c++中虚函数表的概念,在大量对象创建中,可以节省内存占用。

struct _MediaClass {
  GObjectClass parent_class;
  void (*unpacked)  (Media *media);
  void (*throw_out) (Media *media, gboolean permanent);
};
struct _Media {
  GObject parent_instance;
  guint inv_nr;
  GString *location;
  GString *title;
  gboolean orig_package;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

 但是阅读kurento代码,要是按照先前说的,g_object_new(TYPE_XXX,…)是很少找到的,似乎没有函数调用,就让人怀疑,它的c层对象到底是怎么创建的。而且可以找到很多XXX_get_type只有头文件里有定义,却在c文件里找不到实现。例如这一个:

GType kms_webrtc_endpoint_get_type (void);
  • 1

 这个疑惑就需要参考[5],利用到了GObject中的一个宏定义G_DEFINE_TYPE。

#define kms_webrtc_endpoint_parent_class parent_class
G_DEFINE_TYPE (KmsWebrtcEndpoint, kms_webrtc_endpoint,
    KMS_TYPE_BASE_RTP_ENDPOINT);
  • 1
  • 2
  • 3

 仔细看下kmswebrtcendpoint.c中的函数实现,会发现所有的函数都是 kms_webrtc_endpoint打头的。G_DEFINE_TYPE这个宏帮助实现了kms_webrtc_endpoint_get_type这个函数。
 疑惑依然没有解决,c对象是怎么创建的。kurento使用了gstreamer中的一个奇技淫巧,就是它的plugin动态加载机制。
 可以看下,kms-core/src/gst-plugins目录下的的c文件,几乎每一个文件都有这个宏 #define PLUGIN_NAME “xxxxxxx”,例如这个:

#define PLUGIN_NAME "webrtcendpoint"
//对应的还有这个函数
gboolean
kms_webrtc_endpoint_plugin_init (GstPlugin * plugin)
{
  return gst_element_register (plugin, PLUGIN_NAME, GST_RANK_NONE,
      KMS_TYPE_WEBRTC_ENDPOINT);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

 所以在C++代码里,看不到g_object_new这个函数的调用,但是有大量的gst_element_factory_make函数调用。例如kms-elements/src/server/implementation/objects/WebRtcEndpointImpl.cpp中

#define FACTORY_NAME "webrtcendpoint"
WebRtcEndpointImpl::WebRtcEndpointImpl (const boost::property_tree::ptree &conf,
                                        std::shared_ptr<MediaPipeline>
                                        mediaPipeline, bool recvonly,
                                        bool sendonly, bool useDataChannels,
                                        std::shared_ptr<CertificateKeyType> certificateKeyType) :
  BaseRtpEndpointImpl (conf,
                       std::dynamic_pointer_cast<MediaObjectImpl>
                       (mediaPipeline), FACTORY_NAME)
{
  if (recvonly) {
    g_object_set (element, "offer-dir", GST_SDP_DIRECTION_RECVONLY, NULL);
  }

  if (sendonly) {
    g_object_set (element, "offer-dir", GST_SDP_DIRECTION_SENDONLY, NULL);
  }

  if (useDataChannels) {
    g_object_set (element, "use-data-channels", TRUE, NULL);
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

 BaseRtpEndpointImpl这个对象最终继承自MediaElementImpl,这个类会在其构造函数里创建GElement对象,这个对象是属于C层的。

MediaElementImpl::MediaElementImpl (const boost::property_tree::ptree &config,
                                    std::shared_ptr<MediaObjectImpl> parent,
                                    const std::string &factoryName) : MediaObjectImpl (config, parent)
{
element = gst_element_factory_make(factoryName.c_str(), nullptr);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

 在这个例子下,最终调用的就是”webrtcendpoint”这个plugin,创建 KmsWebrtcEndpoint这个C层的对象。
 创建完成后,就可以对这个element设置属性,例如

 g_object_set (element, "offer-dir", GST_SDP_DIRECTION_SENDONLY, NULL);
  • 1

 这个属性,在kms-core/src/gst-plugins/commons/kmsbasertpendpoint.c中有定义,可以全局搜索下”offer-dir”:

  g_object_class_install_property (object_class, PROP_OFFER_DIR,
      g_param_spec_enum ("offer-dir", "Offer direction", "Offer direction",
          KMS_TYPE_SDP_DIRECTION, DEFAULT_OFFER_DIR,
          G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
static void
kms_base_rtp_endpoint_set_property (GObject * object, guint property_id,
    const GValue * value, GParamSpec * pspec)
{
    case PROP_OFFER_DIR:
      self->priv->offer_dir = g_value_get_enum (value);//对这个参数进行配置
      break;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

 同c层的交互,是通过信号来完成的,例如:

void
WebRtcEndpointImpl::gatherCandidates ()
{
  gboolean ret;

  g_signal_emit_by_name (element, "gather-candidates", this->sessId.c_str (),
                         &ret);

  if (!ret) {
    throw KurentoException (ICE_GATHER_CANDIDATES_ERROR,
                            "Error gathering candidates");
  }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

 c层接收到”gather-candidates”信号后,会调用相应的函数进行处理。G_STRUCT_OFFSET (KmsWebrtcEndpointClass, gather_candidates),获取的是回调函数在结构体中的地址偏移信息,也就是调用KmsWebrtcEndpointClass中的 gather_candidates这个函数进行处理。

  kms_webrtc_endpoint_signals[SIGNAL_GATHER_CANDIDATES] =
      g_signal_new ("gather-candidates",
      G_TYPE_FROM_CLASS (klass),
      G_SIGNAL_ACTION | G_SIGNAL_RUN_LAST,
      G_STRUCT_OFFSET (KmsWebrtcEndpointClass, gather_candidates), NULL, NULL,
      __kms_webrtc_marshal_BOOLEAN__STRING, G_TYPE_BOOLEAN, 1, G_TYPE_STRING);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

 关于插件的动态加载机制,可以参考[7]。在kms-core.c中有这样一个宏GST_PLUGIN_DEFINE。可以在kurento_init中看看都加载了那些插件。kurento_init应该是gstreamer查找到动态库路径后会执行的函数。

static gboolean
kurento_init (GstPlugin * kurento)
{
  if (!kms_agnostic_bin2_plugin_init (kurento))
    return FALSE;

  if (!kms_agnostic_bin3_plugin_init (kurento))
    return FALSE;

  if (!kms_filter_element_plugin_init (kurento))
    return FALSE;

  if (!kms_hub_port_plugin_init (kurento))
    return FALSE;

  if (!kms_audio_mixer_plugin_init (kurento))
    return FALSE;

  if (!kms_audio_mixer_bin_plugin_init (kurento))
    return FALSE;

  if (!kms_bitrate_filter_plugin_init (kurento))
    return FALSE;

  if (!kms_buffer_injector_plugin_init (kurento))
    return FALSE;

  if (!kms_pass_through_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_src_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_sink_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_duplex_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_sdp_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_rtp_plugin_init (kurento))
    return FALSE;

  if (!kms_dummy_uri_plugin_init (kurento))
    return FALSE;

  return TRUE;
}

GST_PLUGIN_DEFINE (GST_VERSION_MAJOR,
    GST_VERSION_MINOR,
    kmscore,
    "Kurento core",
    kurento_init, VERSION, GST_LICENSE_UNKNOWN, "Kurento",
    "http://kurento.com/")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57

 在kms-elements中这个宏GST_PLUGIN_DEFINE出现多次,读者可以全局搜索一下。
 总结,kurento的这种松耦合的代码处理逻辑,增加了学习难度。至于其逻辑处理部分,我还没有看。学习kurento,需要学习一些基础知识,例如GObject和GStreamer。kurento在编译中会自动生成一些代码,在各个文件夹下的generated-cpp,要是没有这个文件,代码读起来逻辑不完整,找不到线索。我编译后的代码,已上传[8]。

猜你喜欢

转载自blog.csdn.net/liuweihui521/article/details/80930992