Mongoose7新特性介绍及poll过程详解(清明小长假系列1)

1,269 阅读11分钟

Mongoose7新特性介绍及poll过程详解(清明小长假系列1)

注意:

这个是mongoose server——嵌入式的服务器,而不是mongoose.js~

温馨提示:希望看本文之前能够对mongoose服务器有一个大概的了解,并且阅读过官网的最新文档最佳~

前言

终于到了清明小长假了(才一天!)不过确实给了打工人一个喘息的机会,大家都出去踏踏青、旅旅游,舒缓一下心情。正好也借此机会,总结梳理一下一季度的收获和总结: 再过去的三个月里,大部分时间都是和嵌入式的服务器在打交道,而作为嵌入式当中比较流行的框架mongoose,自然是关注的重点。而很巧的是mongoose在去年的12月底发布了全新的7.0大版本。于是在过去的三个月里,我熟悉了一下全新的mongoose7的特性以及进行了简单的调试,对7.0版本有了一个大概的了解,现在就和大家一起来分享一下吧~

一、去其糟粕,取其精华

mongoose的7.0版本相比较于6.0版本,裁剪掉了非常多的功能,有一些是去掉了糟粕,而有一些我觉得还不错的特性却被一起裁剪掉了(不知道是不是因为cesanta公司的自己做的基于mongoose的服务器解决方案卖不出去了,所以故意这么干的!)接下来我们一一来看一下吧:

1-1、摈弃CGI

7.0版本彻底抛弃了CGI,在源码当中已经没有CGI的部分了。(cesanta:没了CGI你们就得自己写整合的业务框了,写不了就来买我的框架吧,哈哈)猜测主要的原因是CGI的使用太消耗内存了,相比较于直接写业务模块,简直不能同日而语,而且尤其是在目前这种智能家居盛行,人机交互愈发频繁的业务场景下,CGI确实力不从心了。我们来看一下mongoose中如何调用CGI: 1、设置环境变量,为接下来fork的子进程做准备,将一些http请求的信息复制到子进程。 2、fork子进程,然后再执行exec函数族。 所以在这个过程,我们会起两个子进程来处理这个cgi的过程,以及设置一些额外的环境变量加剧内存的消耗。这对于低功耗的嵌入式设备来说是很致命的,所以在7.0版本中,mongoose彻底牺牲了CGI,换取更大的发展前景。

1-2、套接字read逻辑的改变

重大的改变,可以说mongoose6中的套接字的read逻辑并不是完整意义上的非阻塞,其原因在于,当mongoose在select发现一个sokcet可读的时候,会起一个循环来一直读这个套接字(每个循环读最大1460个字节),直到读完为止。这就会产生一个问题,如果我在上传大文件的时候,且网络信号良好的时候,我就会一直读这个句柄一直接收,导致无法处理其他连接的句柄,变相阻塞了整个服务器,我们来看一下mongoose6.x中的recv的逻辑:

static int mg_do_recv(struct mg_connection *nc) {
  int res = 0;
  char *buf = NULL;
  size_t len = (nc->flags & MG_F_UDP ? MG_UDP_IO_SIZE : MG_TCP_IO_SIZE);
  if ((nc->flags & (MG_F_CLOSE_IMMEDIATELY | MG_F_CONNECTING)) ||
      ((nc->flags & MG_F_LISTENING) && !(nc->flags & MG_F_UDP))) {
    return -1;
  }
  do {
    len = recv_avail_size(nc, len);
    if (len == 0) {
      res = -2;
      break;
    }
    if (nc->recv_mbuf.size < nc->recv_mbuf.len + len) {
      mbuf_resize(&nc->recv_mbuf, nc->recv_mbuf.len + len);
    }
    buf = nc->recv_mbuf.buf + nc->recv_mbuf.len;
    len = nc->recv_mbuf.size - nc->recv_mbuf.len;
    if (nc->flags & MG_F_UDP) {
      res = mg_recv_udp(nc, buf, len);
    } else {
      res = mg_recv_tcp(nc, buf, len);//这里会拉起MG_EV_RECV回调,每收1460个字节拉起一次
    }
    //只要能读 就一直循环
  } while (res > 0 && !(nc->flags & (MG_F_CLOSE_IMMEDIATELY | MG_F_UDP))); 
  return res;
}

而在7版本中取消了这一循环,每一次轮询该套接字,只能接收一次(最大1024字节),完成后拉起回调后就轮询下一个套接字,而不会阻塞在这里。

1-3、放弃支持multipart-format

7.0版本也放弃了对multipart-format的支持,转而支持分片上传模式。这个也是基于上一条的修改来配套使用,因为我们现在单轮循环只接收1024个字节,如果还使用formdata的方式一次上传一整个文件,我们在解析的时候,就会需要很长的时间来解析body的内容,可能要进过几百轮轮询才能收完一个http请求,然后在解析,那么针对单个http请求解析就太慢了(其实感觉这个聊胜于无,如果是上传文件,慢点就慢点,别影响我其他请求就行了)我们来看一下mongoose7中的上传代码吧:

int mg_http_upload(struct mg_connection *c, struct mg_http_message *hm,
                   const char *dir) {
  char offset[40] = "", name[200] = "", path[256];
  mg_http_get_var(&hm->query, "offset", offset, sizeof(offset));
  mg_http_get_var(&hm->query, "name", name, sizeof(name));
  if (name[0] == '\0') {
    mg_http_reply(c, 400, "", "%s", "name required");
    return -1;
  } else {
    FILE *fp;
    size_t oft = strtoul(offset, NULL, 0);
    snprintf(path, sizeof(path), "%s%c%s", dir, MG_DIRSEP, name);
    LOG(LL_DEBUG,
        ("%p %d bytes @ %d [%s]", c->fd, (int) hm->body.len, (int) oft, name));
    if ((fp = mg_fopen(path, oft == 0 ? "wb" : "ab")) == NULL) {
      mg_http_reply(c, 400, "", "fopen(%s): %d", name, errno);
      return -2;
    } else {
      fwrite(hm->body.ptr, 1, hm->body.len, fp);
      fclose(fp);
      mg_http_reply(c, 200, "", "");
      return hm->body.len;
    }
  }
}

这里其实有一个小问题,mongoose7就是在解析http消息的时候,是一次性解析完才拉起用户注册的http回调的,所以数据会一直滞留在内存当中。当我们前端blob在传输文件分片的时候就不宜把分片设置过大,导致嵌入式设备内存占用过高。相比之下,mongoose6版本,在接收文件的时候,则是直接会把http头部信息给释放掉,专心去处理每一个multipart的chunk,每处理一个chunk,都会把该chunk删除掉。

1-4、静态资源请求逻辑的调整

关于mongoose7的静态资源请求逻辑的调整,其实我有很多的不解之处。我们先来说一下不同的地方。最大的不同就是mongoose7版本取消了对Range头的解析。 Range头是支持断点下载不可获取的一个部分,同时前端的<video />标签和<audio />标签在播放控制、拖拽进度条的时候,都是会使用到Range头的,如果mongoose不支持该头了,web页面的视频/音频的拖拽功能就会出现异常了。

1-5、配置选项不支持额外的http请求头了

这个问题不大,mongoose7的初始配置选项结构体删除了char *extra_headers这个成员了。不过mg_printf()函数仍然在,所以只是在写请求头的时候会变得繁琐一些。

1-6、回调函数新增入参

用户注册的回调函数新增了一个入参void *fn_data这个有点像mongoose6里面的ctx的user_data,只不过mongoose7把他显式的传入了,并且可以在监听绑定的阶段就传入,之后每个连接都会继承这个数据(注意继承是指针,指向同一片内存!谨慎修改)

这个玩意可以让我们在配置自己的服务器的时候传入一些默认的配置,这个更多的是将mongoose作为面向对象风格开发的时候使用。这样我可以将配置作为一个结构体传入保存,而非全局变量来访问。不仅可以防止过多的全局变量,还可以每个服务器保存不同配置(后续有空我会封装一下mongoose,用到的类似面向对象的思想,哼,谁说C语言不能面向对象~ )

1-7、服务器初始化的变化

初始化最主要的变化就是,绑定地址的方式变了:从原来的mg_bind()变成了

struct mg_connection *mg_http_listen(struct mg_mgr *mgr, const char *url,mg_event_handler_t fn, void *fn_data)

直接申明我是http的协议,且修改了传入地址的方式,mongoose6的mg_bind()只需要绑定端口,其余的事情mongoose内部就帮你处理好了,而在mongoose7中,你需要传入一个完整的url,类似于:https://206.123.6.1:8000所以你可能需要自己再去手写一下解析设备本身ip地址的函数了~经过测试貌似并不支持ip地址为localhost或者127.0.0.1这样的表示自身。

mongoose7的poll过程

其实说mongoose是poll不太得准确,实质上mongoose是通过select来实现整个的轮询过程的。但为了方便,下文都会使用poll来代替,问题也不大,这俩玩意时间复杂度都是O(n)

接下来我们就要来分析一下mongoose7的整个poll的过程吧。

2-1、服务器的开始:监听套接字

mongoose使用的连接结构体我们就不多说了,看一下官方的文档就能大概了解了~ mongoose7依然是将监听套接字和普通套接字放在一个链表里面进行轮询。然后在轮询到该套接字的时候,而该套接字又是可读的,且还标志着是监听套接字,那么就会进入accept的逻辑,我们就不展开讲了。

2-2、select的准备:fd_set

这个部分也是基本和mongoose6一样的。所有的连接的套接字都会被放入监听是否可读的fd_set集合。然后根据连接的send_buf是不是有数据,将该套接字放入监听可写的fd_set

测试了一下貌似只要是放入可写监听集合fd_set的套接字状态正常,一定是可以被select的到的

接下来就是select了,然后将连接的标志位置位对应的可读或者可写状态,完成select过程。源码中该过程在mg_iotest中完成;

static void mg_iotest(struct mg_mgr *mgr, int ms) {
#if MG_ARCH == MG_ARCH_FREERTOS
  struct mg_connection *c;
  for (c = mgr->conns; c != NULL; c = c->next) {
    FreeRTOS_FD_CLR(c->fd, mgr->ss, eSELECT_WRITE);
    if (c->is_connecting || (c->send.len > 0 && c->is_tls_hs == 0))
      FreeRTOS_FD_SET(c->fd, mgr->ss, eSELECT_WRITE);
  }
  FreeRTOS_select(mgr->ss, pdMS_TO_TICKS(ms));
  for (c = mgr->conns; c != NULL; c = c->next) {
    EventBits_t bits = FreeRTOS_FD_ISSET(c->fd, mgr->ss);
    c->is_readable = bits & (eSELECT_READ | eSELECT_EXCEPT) ? 1 : 0;
    c->is_writable = bits & eSELECT_WRITE ? 1 : 0;
  }
#else
  struct timeval tv = {ms / 1000, (ms % 1000) * 1000};
  struct mg_connection *c;
  fd_set rset, wset;
  SOCKET maxfd = 0;
  int rc;

  FD_ZERO(&rset);
  FD_ZERO(&wset);

  for (c = mgr->conns; c != NULL; c = c->next) {
    // c->is_writable = 0;
    // TLS might have stuff buffered, so dig everything
    // c->is_readable = c->is_tls && c->is_readable ? 1 : 0;
    if (c->is_closing || c->is_resolving || FD(c) == INVALID_SOCKET) continue;
    FD_SET(FD(c), &rset);
    if (FD(c) > maxfd) maxfd = FD(c);
    if (c->is_connecting || (c->send.len > 0 && c->is_tls_hs == 0))
      FD_SET(FD(c), &wset);
  }

  if ((rc = select(maxfd + 1, &rset, &wset, NULL, &tv)) < 0) {
    LOG(LL_DEBUG, ("select: %d %d", rc, MG_SOCK_ERRNO));
    FD_ZERO(&rset);
    FD_ZERO(&wset);
  }

  for (c = mgr->conns; c != NULL; c = c->next) {
    // TLS might have stuff buffered, so dig everything
    c->is_readable = c->is_tls && c->is_readable
                         ? 1
                         : FD(c) != INVALID_SOCKET && FD_ISSET(FD(c), &rset);
    c->is_writable = FD(c) != INVALID_SOCKET && FD_ISSET(FD(c), &wset);
  }
#endif
}

2-3、poll完成,遍历连接,拉起回调

通过select之后我们完成了一轮poll的轮询,并且把每个连接的标志位进行了标记,接下来就是遍历整个连接的链表进行分别的处理。拉起回调的仍然是mg_call()函数,不过逻辑稍有变化:

void mg_call(struct mg_connection *c, int ev, void *ev_data) {
  if (c->pfn != NULL) c->pfn(c, ev, ev_data, c->pfn_data);
  if (c->fn != NULL) c->fn(c, ev, ev_data, c->fn_data);
}

变得非常简单,且必定会拉起协议默认回调和用户自定义回调,这个和mongoose6版本有很大的出入,mongoose6如果拉起了默认回调就不会拉起用户回调了。

2-3-1、default poll

在遍历每一个连接是最开始会拉起一次回调(依然是),并传入MG_EV_POLL事件,表示这一轮轮询的结束。这一次拉起回调函数其实非常的关键,也为mongoose扩展成多线程提供了可能(可以看github上源码的example里面有多线程的例子),同时配合新的mg_call()也为我们扩展mongoose提供了无限的可能。

2-3-2、判断连接状态

首先,判断的是连接是否为监听连接,是的话,且可读就进入accept流程。 然后,判断是不是https是的话需要解密https消息。 再者,判断是否可读或者可写,然后执行读操作好写操作,然后拉起回调(MG_EV_WRITE和MG_EV_READ)

2-3-3、源码

来看一下源码:

void mg_mgr_poll(struct mg_mgr *mgr, int ms) {
  struct mg_connection *c, *tmp;
  unsigned long now;

  mg_iotest(mgr, ms);
  now = mg_millis();
  mg_timer_poll(now);

  for (c = mgr->conns; c != NULL; c = tmp) {
    tmp = c->next;
    mg_call(c, MG_EV_POLL, &now);
    LOG(LL_VERBOSE_DEBUG,
        ("%lu %c%c %c%c%c%c%c", c->id, c->is_readable ? 'r' : '-',
         c->is_writable ? 'w' : '-', c->is_tls ? 'T' : 't',
         c->is_connecting ? 'C' : 'c', c->is_tls_hs ? 'H' : 'h',
         c->is_resolving ? 'R' : 'r', c->is_closing ? 'C' : 'c'));
    if (c->is_resolving || c->is_closing) {
      // Do nothing
    } else if (c->is_listening && c->is_udp == 0) {
      if (c->is_readable) accept_conn(mgr, c);
    } else if (c->is_connecting) {
      if (c->is_readable || c->is_writable) connect_conn(c);
    } else if (c->is_tls_hs) {
      if ((c->is_readable || c->is_writable)) mg_tls_handshake(c);
    } else {
      if (c->is_readable) read_conn(c, ll_read);
      if (c->is_writable) write_conn(c);
    }

    if (c->is_draining && c->send.len == 0) c->is_closing = 1;
    if (c->is_closing) close_conn(c);
  }
}

我们在理解整个流程的时候,最为关键的其实是拉起回调的时机,如果能把每个拉起回调的时机把握好,那么整个poll轮询的过程才算是熟稔于心。

2-4、流程图

接下来就是流程图了,不废话直接上图!(熬夜手画 有点丑后期找个好点的流程图软件把他替换了T_T)

mg_poll.png

小结

这篇文章就到这里了~ 简单的介绍了一下mongoose7.0的一些新的特性和废弃掉的一些特性,以及mongoose7.0的poll流程和逻辑~重点是拉起回调的时机,这才是poll的核心~作为一个嵌入式框架,还是非常好用的,目前我司很多项目也会使用该框架,经得起时间考验的框架~