目标:

    本章我们将分析SRS4.0 RTMP服务模块与推流相关的代码处理逻辑。


内容:

    根据上节内容可知,SRS4.0针对RTMP推流客户端的处理逻辑,主要在协程SrsRtmpConn::stream_service_cycle()中通过调用SrsRtmpConn::publishing()函数进行处理。(为了方便理解,下面函数使用了简化后的伪代码,但不影响理解函数的主流程)
1、检测是否可以向当前的source对象推流,如果source的状态是已经有人在推流了,则acquire_publish函数返回失败。
2、创建推流端接收协程SrsPublishRecvThread,这个协程用于实际接收客户端向服务器发送的推流数据。
3、SrsRtmpConn协程将在do_publishing函数内部循环处理,主要工作是统计接收的数据,监控上述接收协程。

srs_error_t SrsRtmpConn::publishing(SrsLiveSource* source)
{http_hooks_on_publish();   // 此回调函数向外通知客户端推流开始// 检测是否可以向当前的source对象推流,如果source的状态是已经有人在推流了,则acquire_publish函数返回失败if ((err = acquire_publish(source)) == srs_success) {// 创建推流端接收协程,这个协程用于实际接收客户端向服务器发送的推流数据SrsPublishRecvThread rtrd(rtmp, req, srs_netfd_fileno(stfd), 0, this, source, );  // SrsRtmpConn协程将在do_publishing函数内部循环处理,// 同时do_publishing函数内部还会启动SrsPublishRecvThread协程err = do_publishing(source, &rtrd);  rtrd.stop();  // 执行到这里,表示SrsRtmpConn协程退出循环,这里是主动结束之前创建的接收协程}release_publish(source); // 推流结束,释放source中的推流信息http_hooks_on_unpublish();   // 此回调函数向外通知客户端推流结束
}

SrsRtmpConn协程在推流端的处理,主要工作是统计接收的数据,并检测如果没有接收到新数据,则结束协程

srs_error_t SrsRtmpConn::do_publishing(SrsLiveSource* source, SrsPublishRecvThread* rtrd)
{rtrd->start();  // 启动接收协程while (true) {  // conn协程好像主要做统计工作,以及判断接收数据超时后退出do_publishingif ((err = trd->pull()) != srs_success)  // 协程运行出错,则结束协程return srs_error_wrap();if (nb_msgs == 0) {  rtrd->wait(20秒);  // 如果是第一次接收报文,conn协程在此阻塞20秒} else {   rtrd->wait(5秒);   // 如果是普通接收报文,conn协程在此阻塞5秒}    if ((rtrd->error_code()) != srs_success)  return srs_error_wrap(); // 如果接收协程错误则退出循环,结束协程if (rtrd->nb_msgs() <= nb_msgs)  return srs_error_wrap(); // 超时检测发现没有接收到新数据则退出循环,结束协程nb_msgs = rtrd->nb_msgs(); // 更新数据,此变量是int64,不用担心溢出翻转stat->on_video_frames();  // 通过单件类SrsStatistic,刷新每条流的接收视频帧计数}
}

推流端接收协程的创建和处理逻辑:
在创建SrsPublishRecvThread对象时,它的构造函数会同时创建一个SrsRecvThread协程对象,所以,真正的推流端接收协程是SrsRecvThread::do_cycle()

SrsPublishRecvThread::SrsPublishRecvThread(): trd(this, rtmp_sdk, tm, parent_cid)
{}srs_error_t SrsRecvThread::cycle() {rtmp->set_recv_timeout(SRS_UTIME_NO_TIMEOUT); // 设置为阻塞式读取do_cycle();  // rtmp->set_recv_timeout(timeout); // 读取连接结束,恢复默认timeout
}srs_error_t SrsRecvThread::do_cycle()
{rtmp->recv_message(&msg); // 此函数用于得到一个完整RTMP Message消息,即一个完整的音频帧或视频帧// 到此我们大概知道,具体的RTMP协议都封装在SrsRtmpServer类中// 对于初学者,可以先不关心SrsRtmpServer类的实现细节pumper->consume(msg);  // 对于推流端,这里的pumper对象类型为SrsPublishRecvThread// 对于拉流端,这里的pumper对象类型为SrsQueueRecvThread
}// 接收推流端数据并处理
srs_error_t SrsPublishRecvThread::consume(SrsCommonMessage* msg)
{_conn->handle_publish_message(_source, msg); // 最终,SrsRtmpConn接收推流报文if (++nn_msgs_for_yield_ >= 15) {nn_msgs_for_yield_ = 0;srs_thread_yield();   // 连续接收15帧数据后,当前协程主动让出CPU}
}

处理一个完整的RTMP报文

srs_error_t SrsRtmpConn::handle_publish_message()
{// 这里是接收AMF编码的RTMP控制命令,实际上好像意义不大,更多可能是防错处理if (msg->header.is_amf0_command() || msg->header.is_amf3_command()) {rtmp->decode_message(msg, &pkt);if (dynamic_cast<SrsFMLEStartPacket*>(pkt)) {SrsFMLEStartPacket* unpublish = dynamic_cast<SrsFMLEStartPacket*>(pkt);rtmp->fmle_unpublish(); // for fmle, drop others except the fmle start packet.}return err;}  // 处理RTMP相关的video, audio, MetaData(元数据)报文,// 首先处理的是MetaData报文// 其次是视频第一帧AVC sequence header和音频第一帧AAC sequence header// 最后是普通的音视频数据帧process_publish_message(source, msg);   // video, audio, data message
}

按照RTMP协议,推拉流过程中:

  1. 首先,发送的总是是MetaData报文
  2. 其次,是视频第一帧AVC sequence header和音频第一帧AAC sequence header
  3. 最后,是普通的音视频数据帧
srs_error_t SrsRtmpConn::process_publish_message()
{if (info->edge) {  // 如果sever工作在edge模式,则调用此接口向源站转发报文,然后直接返回// 此时可以看到,edge模式下,SRS属于纯转发,所以不会在本地做HLS(ts、m3u8文件)处理// 同样,对于edge模式下的,推流处理逻辑主要在SrsPublishEdge和SrsEdgeForwarder类实现// 对于初学者,可暂时不关注这两个类,只要知道SRS对edge模式的设计规格是:// 推流到edge上时,edge会直接将流转发给源站;播放edge上的流时,edge会回源拉流。return  source->on_edge_proxy_publish(msg);  }   // 非edge模式,报文分类传入source对象if (msg->header.is_audio()) {  // 音频报文处理分支return source->on_audio(msg);  }     if (msg->header.is_video()) {  // 视频报文处理分支return source->on_video(msg);  }     if (msg->header.is_aggregate()) {  // 聚合消息处理分支return source->on_aggregate(msg);  }  // onMetaData音视频元数据处理分支if (msg->header.is_amf0_data() || msg->header.is_amf3_data()) {rtmp->decode_message(msg, &pkt);source->on_meta_data(msg, metadata); return;}
}

因为,MetaData报文和sequence header在推拉流开始时只会发送一次,所以,SRS作为服务端必须将RTMP推流客户端的MetaData报文和sequence header报文缓存起来,用于满足随时增加的拉流客户端。

  1. MetaData报文格式如下:

  1. AVC sequence header报文格式如下: (属于全局关键编码信息,所以也是关键帧)
+----------------------------------------------------------------------------+
|FrameType(1 byte) 高4位表示帧类型(1表示关键帧),低4位表示编码类型(7表示H264编码)    |
+----------------------------------------------------------------------------+
|0x00 0x00 0x00 0x00 (4 byte)                                                |
+----------------------------------------------------------------------------+
|configurationVersion (1 byte)    0x01                                       |
+----------------------------------------------------------------------------+
|AVCProfileIndication (1 byte)    SPS[1]                                     |
+----------------------------------------------------------------------------+
|profile_compatibility (1 byte)   SPS[2]                                     |
+----------------------------------------------------------------------------+
|AVCLevelIndication (1 byte)      SPS[3]                                     |
+----------------------------------------------------------------------------+
|lengthSizeMinusOne (1 byte)      0xff                                       |
+----------------------------------------------------------------------------+
|sps number (1 byte)              SPS的序号0xE1                               |
+----------------------------------------------------------------------------+
|sps data length (2 byte)         去掉分隔符00000001之后的实际长度               |
+----------------------------------------------------------------------------+
|sps data                                                                    |
+----------------------------------------------------------------------------+
|pps number (1 byte)              PPS的序号0x01                               |
+----------------------------------------------------------------------------+
|pps data length (2 byte)         去掉分隔符00000001之后的实际长度               |
+----------------------------------------------------------------------------+
|pps data                                                                    |
+----------------------------------------------------------------------------+

wireshark抓包报文格式:

  1. RTMP对普通H264视频数据的封装
+----------------------------------------------------------------------------+
| FrameType[1 byte] 高4位表示是否是关键帧(1:关键帧),低4位表示编码类型(7表示H264编码) |
+----------------------------------------------------------------------------+
| 0x01 0x00 0x00 0x00 (4 byte)                                               |
+----------------------------------------------------------------------------+
| NALU size (4 byte)  去掉分隔符00000001之后的长度                              |
+----------------------------------------------------------------------------+
| NALU data                                                                  |
+----------------------------------------------------------------------------+

音视频报文的处理流程基本一致,其中有一个mix_queue用于解决时间戳乱序问题

srs_error_t SrsLiveSource::on_audio(SrsCommonMessage* shared_audio)
srs_error_t SrsLiveSource::on_video(SrsCommonMessage* shared_video)
{last_packet_time = shared_video->header.timestamp; // 更新最新接收报文的时间// 检查视频报文的封装格式是否正确,错误报文直接丢弃,判断依据就是RTMP封装的第一个字节frame_typeif (!SrsFlvVideo::acceptable(shared_video->payload, shared_video->size)) {return err;}SrsSharedPtrMessage msg;msg.create(shared_video);// 将shared_video中的视频数据转移到msg对象内// 如果不需要mix_correct算法,则直接交给on_video_imp处理if (!mix_correct) { return on_video_imp(&msg); }   // 否则将音视频报文放入算法队列,执行mix_correctmix_queue->push(msg.copy());                 SrsSharedPtrMessage* m = mix_queue->pop();   // 从算法队列取报文并处理if (m->is_audio()) { err = on_audio_imp(m); } else {   err = on_video_imp(m); }
}

mix_queue内部其实就是一个以timestamp为key的multimap容器,这样放入的数据就会自然按照报文的时间戳排序。
无论是否经过mix_queue排序,最终处理音视频数据的都是SrsLiveSource::on_audio_imp()和SrsLiveSource::on_video_imp()。

srs_error_t SrsLiveSource::on_video_imp(SrsSharedPtrMessage* msg)
{// AVC sequence header就是SPS+PPS的RTMP包,具体根据报文的第1、2字节进行判断:// 第一个字节是FrameType,其中:高4位表示是否是关键帧(1:关键帧,2、3、4、5表示其它帧类型),//                          低4位表示编码类型(7表示H264编码,12表示H265编码)// 第二个字节:0表示当前Msg是SPS+PPS数据,1表示当前Msg是H264视频帧// 这里就是根据上面的原则判断报文是否是AVC sequence header(SPS+PPS)bool is_sequence_header = SrsFlvVideo::sh(msg->payload, msg->size); // 如果是AVC sequence header数据,需要和meta对象中保存的数据比较,是否为重复数据// 将最新的SPS+PPS数据保存到meta对象,if (is_sequence_header && (err = meta->update_vsh(msg)) != srs_success) {return srs_error_wrap(err, "meta update video");}// 服务器只有工作在源站模式才能走到这里,此函数内部执行源站的全部工作,内容多且独立,后面单独分析// 1) hls->on_video()  HLS处理// 2) dash->on_video() DASH处理// 3) dvr->on_video()  DVR处理// 4) hds->on_video()  HDS处理// 5) forwarder->on_video()  直接向指定站点转发视频数据if ((err = hub->on_video(msg, is_sequence_header)) != srs_success) {return srs_error_wrap(err, "hub consume video");}// 如果是类似RTMP推流-->WebRTC拉流场景,则有相应的bridger_->on_video()处理if (bridger_ && (err = bridger_->on_video(msg)) != srs_success) {return srs_error_wrap(err, "bridger consume video");}// 典型的RTMP直播场景,一个推流端可能同时对应多个拉流端,// 每个拉流端都需要创建一个属于自己的SrsLiveConsumer消费者对象// SRS接收到推流客户端的数据后,在这里将数据复制到每个拉流端的SrsLiveConsumer缓存队列中for (int i = 0; i < (int)consumers.size(); i++) {SrsLiveConsumer* consumer = consumers.at(i);  // 遍历拉流端队列并复制消息if ((err = consumer->enqueue(msg, atc, jitter_algorithm)) != srs_success) {return srs_error_wrap(err, "consume video");}}// AVC sequence header(SPS+PPS)数据不需要GOP缓存,在这里直接返回if (is_sequence_header) { return err; } gop_cache->cache(msg);  // 普通视频帧放入GOP缓存// 关于ATC,大概描述如下// SRS默认ATC是关闭,即给客户端的RTMP流永远从0开始。// 开启ATC之后,RTMP流的时间戳就是ATC时间(即绝对时间),这样的目的大概是为了实现HLS的主备时间一致if (atc) {if (meta->vsh()) { meta->vsh()->timestamp = msg->timestamp; }if (meta->data()) { meta->data()->timestamp = msg->timestamp; }}
}

on_audio_imp()和on_video_imp()的处理逻辑比较一致:

  • 判断并缓存AAC sequence header和AVC sequence header数据
  • SrsOriginHub::on_audio和SrsOriginHub::on_video执行音视频的源站处理逻辑(HLS/DVR/FORWARDER)
  • 通过SrsRtcFromRtmpBridger::on_audio和SrsRtcFromRtmpBridger::on_video向RTC模块转发
  • 遍历本地拉流端SrsLiveConsumer对象,通过SrsLiveConsumer::enqueue,音视频数据进入消费者队列
  • 使用SrsGopCache::cache缓存一组完整的视频GOP
srs_error_t SrsLiveSource::on_audio_imp(SrsSharedPtrMessage* msg)
{// 判断报文是否是AAC sequence header数据bool is_aac_sequence_header = SrsFlvAudio::sh(msg->payload, msg->size);bool is_sequence_header = is_aac_sequence_header;// 如果是AAC sequence header数据,需要和meta对象中保存的数据比较,是否为重复数据// 并根据配置信息,决定是否需要丢弃重复的AAC sequence header// 服务器只有工作在源站模式才能走到这里,此函数内部执行源站的全部工作,内容多且独立,后面单独分析// 1) hls->on_audio()   HLS处理// 2) dash->on_audio()  DASH处理// 3)dvr->on_audio()   DVR处理// 4)hds->on_audio()   HDS处理// 5) forwarder->on_audio()   直接向指定站点转发视频数据hub->on_audio(msg);// 如果是类似RTMP推流-->WebRTC拉流场景,则有相应的bridger_->on_audio()处理if (bridger_ && (err = bridger_->on_audio(msg)) != srs_success) {return srs_error_wrap(err, "bridger consume audio");}// SRS接收到推流客户端的数据后,在这里将数据复制到每个拉流端的SrsLiveConsumer缓存队列中if (!drop_for_reduce) {for (int i = 0; i < (int)consumers.size(); i++) {SrsLiveConsumer* consumer = consumers.at(i);if ((err = consumer->enqueue(msg, atc, jitter_algorithm)) != srs_success) {return srs_error_wrap(err, "consume message");}}}// cache the sequence header of aac, or first packet of mp3if (is_aac_sequence_header || !meta->ash()) {if ((err = meta->update_ash(msg)) != srs_success) {return srs_error_wrap(err, "meta consume audio");}}// AVC sequence header(SPS+PPS)数据不需要GOP缓存,在这里直接返回if (is_sequence_header) { return err; } gop_cache->cache(msg);  // 普通视频帧放入GOP缓存// 关于ATC,大概描述如下// SRS默认ATC是关闭,即给客户端的RTMP流永远从0开始。// 开启ATC之后,RTMP流的时间戳就是ATC时间(即绝对时间),这样的目的大概是为了实现HLS的主备时间一致if (atc) {if (meta->vsh()) { meta->vsh()->timestamp = msg->timestamp; }if (meta->data()) { meta->data()->timestamp = msg->timestamp; }}
}

最终,推流端数据通过SrsLiveConsumer::enqueue()函数进入拉流端消费者队列,此函数的处理逻辑如下:

  • 1、使用SrsRtmpJitter::correct()函数,处理音视频数据的时间戳
  • 2、调用SrsMessageQueue::enqueue()函数,将音视频数据保存到一个类似Vector的容器中
  • 3、调用srs_cond_signal(mw_wait)函数,唤醒拉流协程读取数据
srs_error_t SrsLiveConsumer::enqueue(msg)
{// ATC默认不开启时,音视频数据先进入SrsRtmpJitter对象根据算法检查甚至修改报文的时间戳//  full算法:保证报文的时间戳从0开始,且单调递增//  zero算法:只保证时间戳从0开始//  off算法:不做jitter处理if (!atc) {if ((err = jitter->correct(msg, ag)) != srs_success) {return srs_error_wrap(err, "consume message");}}// 将最新接收到的报文入队列,此函数内部会判断如果缓存的报文过多,则丢弃一部分旧报文queue->enqueue(msg, NULL);if (mw_waiting) { // 为了提高读写效率,此标志表示采用批量写方式srs_utime_t duration = queue->duration(); // 计算队列总缓存报文的总时间bool match_min_msgs = queue->size() > mw_min_msgs; // 计算队列总缓存报文的总数量// For ATC, maybe the SH timestamp bigger than A/V packet,// when encoder republish or overflow.// @see https://github.com/ossrs/srs/pull/749if (atc && duration < 0) {srs_cond_signal(mw_wait);mw_waiting = false;return err;}// 如果队列缓存报文的总时间超过mw_duration门限且缓存报文的总数量超过mw_min_msgs// 则调用srs_cond_signal(mw_wait)唤醒拉流(play)协程从队列中取数据if (match_min_msgs && duration > mw_duration) {srs_cond_signal(mw_wait);mw_waiting = false;return err;}}
}

因为服务器的资源有限,不能无限缓存音视频数据,所以SrsMessageQueue::enqueue()函数的处理逻辑如下:

  • 1、音视频报文缓存在一个类似vector的容器中
  • 2、记录缓存队列中报文的start_time和end_time,用于计算已缓存的视频流时长
  • 3、如果缓存的报文超过max_queue_size限制,调用SrsMessageQueue::shrink()丢弃缓存的报文
srs_error_t SrsMessageQueue::enqueue()
{msgs.push_back(msg);   // Message数据包最终进入vector容器// 设置队列缓存报文的av_start_time 和 av_end_time if (msg->is_av()) {if (av_start_time == -1) {av_start_time = srs_utime_t(msg->timestamp * SRS_UTIME_MILLISECONDS);}av_end_time = srs_utime_t(msg->timestamp * SRS_UTIME_MILLISECONDS);}// 默认的最大缓存时间(max_queue_size)一般是30秒,如果被修改为<=0,则表示不做判断,一直缓存???if (max_queue_size <= 0) { return err; }// 如果队列中缓存报文超过max_queue_size,则通过shrink()函数丢弃旧报文while (av_end_time - av_start_time > max_queue_size) {shrink();}return err;
}

SrsMessageQueue::shrink()函数的目的是将缓存的音视频报文之间丢弃,但是此函数内部有一个遍历报文的目的,是为了确定缓存队列中是否有AAC sequence header和AVC sequence header。如果有,则不能丢弃,因为这个玩意推流端只会发送一次,如果丢了,拉流端将无法播放。

void SrsMessageQueue::shrink()
{SrsSharedPtrMessage* video_sh = NULL;SrsSharedPtrMessage* audio_sh = NULL;int msgs_size = (int)msgs.size();// remove all msg// igone the sequence headerfor (int i = 0; i < (int)msgs.size(); i++) {SrsSharedPtrMessage* msg = msgs.at(i);if (msg->is_video() && SrsFlvVideo::sh(msg->payload, msg->size)) {srs_freep(video_sh);video_sh = msg;continue;}else if (msg->is_audio() && SrsFlvAudio::sh(msg->payload, msg->size)) {srs_freep(audio_sh);audio_sh = msg;continue;}srs_freep(msg);}msgs.clear();// update av_start_timeav_start_time = av_end_time;//push_back secquence header and update timestampif (video_sh) {video_sh->timestamp = srsu2ms(av_end_time);msgs.push_back(video_sh);}if (audio_sh) {audio_sh->timestamp = srsu2ms(av_end_time);msgs.push_back(audio_sh);}if (!_ignore_shrink) {srs_trace("shrinking, size=%d, removed=%d, max=%dms", (int)msgs.size(), msgs_size - (int)msgs.size(), srsu2msi(max_queue_size));}
}

综上,推流端处理逻辑大致处理完成,下一章将继续分析拉流端处理逻辑。


总结:

通过分析,我们了解了SRS4.0 RTMP服务模块推流端处理的整体逻辑:
1)推流端一共有两个主要的协程,其中SrsRtmpConn::do_publishing()协程主要工作是统计接收的数据,并检测如果没有接收到新数据,则断开推流端连接。SrsRecvThread::cycle()协程真正接收客户端的推流数据。
2)每个推流客户端对应一个SrsLiveSource对象,每个拉流客户端对应一个SrsLiveConsumer对象。SrsRecvThread::cycle()协程将接收到的RTMP数据包通过SrsLiveSource对象最终复制到每个SrsLiveConsumer对象,最终实现从推流端到拉流端的报文转发。
3)如果SRS服务器工作在edge模式,相关的边缘处理逻辑都封装在SrsPublishEdge类中。
如果服务器工作在orgin模式,相关的处理逻辑(HLS / DVR / Forward)都封装在SrsOriginHub类中。
如果要实现SRS内部的RTMP与WebRTC数据互通,则需要进一步分析ISrsLiveSourceBridger类。

整个过程,可以参考下面的数据流程图。

下一章 5、SRS4.0源代码分析之RTMP拉流处理

4、SRS4.0源代码分析之RTMP推流处理相关推荐

  1. SRS4.0源代码分析之RTMP拉流处理

    目标: 上一节分析了SRS针对推流客户端的处理逻辑,这里接下来分析针对拉流客户端的处理逻辑. SRS拉流端处理逻辑简单说就是SrsRtmpConn::do_playing()协程从SrsLiveCon ...

  2. 5、SRS4.0源代码分析之RTMP拉流处理

    目标: 上一节分析了SRS针对推流客户端的处理逻辑,这里接下来分析针对拉流客户端的处理逻辑. SRS拉流端处理逻辑简单说就是SrsRtmpConn::do_playing()协程从SrsLiveCon ...

  3. 10、SRS4.0源代码分析之WebRTC推流端处理

    目标: 上一节分析了SRS4.0中WebRTC模块的总体架构和软件处理流程.接下来分析SRS4.0 WebRTC模块针对客户端推流连接上各种协议报文的软件处理逻辑. 内容: WebRTC模块在启动过程 ...

  4. 13、SRS4.0源代码分析之GB28181实验环境搭建

    前言 严格的说SRS4.0正式发布版本中已经去掉了GB28181相关的代码(主要时因为该特性还有一些Bug需要修复),本文目的是记录之前学习和使用SRS GB28181推流处理的一些心得. 内容 一. ...

  5. 区块链教程Fabric1.0源代码分析scc(系统链码)

    区块链教程Fabric1.0源代码分析scc(系统链码),2018年下半年,区块链行业正逐渐褪去发展之初的浮躁.回归理性,表面上看相关人才需求与身价似乎正在回落.但事实上,正是初期泡沫的渐退,让人们更 ...

  6. 区块链教程Fabric1.0源代码分析Peer peer channel命令及子命令实现

    区块链教程Fabric1.0源代码分析Peer peer channel命令及子命令实现,2018年下半年,区块链行业正逐渐褪去发展之初的浮躁.回归理性,表面上看相关人才需求与身价似乎正在回落.但事实 ...

  7. 区块链教程Fabric1.0源代码分析Tx(Transaction 交易)一

    区块链教程Fabric1.0源代码分析Tx(Transaction 交易)一,2018年下半年,区块链行业正逐渐褪去发展之初的浮躁.回归理性,表面上看相关人才需求与身价似乎正在回落.但事实上,正是初期 ...

  8. 兄弟连区块链教程Fabric1.0源代码分析configupdate处理通道配置更新

    区块链教程Fabric1.0源代码分析configupdate处理通道配置更新,2018年下半年,区块链行业正逐渐褪去发展之初的浮躁.回归理性,表面上看相关人才需求与身价似乎正在回落.但事实上,正是初 ...

  9. 兄弟连区块链教程Fabric1.0源代码分析Peer peer根命令入口及加载子命令一

    区块链教程Fabric1.0源代码分析Peer peer根命令入口及加载子命令,2018年下半年,区块链行业正逐渐褪去发展之初的浮躁.回归理性,表面上看相关人才需求与身价似乎正在回落.但事实上,正是初 ...

最新文章

  1. MT6575 3G切换2G
  2. ecos无线驱动掉线问题解决方案分析
  3. js表单验证控制代码大全
  4. jquery实现上线翻滚效果公告
  5. WPF基础到企业应用系列7——深入剖析依赖属性(三)
  6. Ansible 获取主机信息模块setup、获取文件详细信息模块stat(学习笔记十)
  7. 不可阻挡的PowerShell :Red Teamer告诉你如何突破简单的AppLocker策略
  8. 史上最简单的 SpringCloud 教程
  9. 5怎么用修改器_经常用电脑辐射大怎么办?这5个习惯防辐射,很多人都知道
  10. 用VBS脚本实现软件定条件开启
  11. directive之require
  12. abl String方法
  13. css图片放大缩小动画
  14. xml文件的注释展示
  15. 【思维导图】巩固你的JavaScript知识体系
  16. foxmail7导入导出数据
  17. ECU扫盲篇——什么是ECU?
  18. 计算机提取干涉条纹原理,两种提取Fabry-Perot干涉条纹圆心的新方法
  19. 物联网利器——ESP8266(入门及环境搭建)
  20. 微信小程序开发之城市定位

热门文章

  1. 华硕无双性能、体验双升级,“里子”“面子”精致又强悍
  2. 2017衢州联赛第四题题解
  3. 针对日语二级的学习方法
  4. IO密集型线程和CPU密集型线程
  5. 以想总结就来博客写写
  6. TabLayout设置指示器的宽度
  7. 拓扑容差如何修改_拓扑编辑
  8. java对象转换为JSON日期格式转换处理
  9. eas报错日记_eas日志收集方式
  10. ac1412. 邮政货车(插头DP)