65.9K
CodeProject 正在变化。 阅读更多。
Home

适用于 IoT 设备的 HTTP 分块传输编码

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2020年11月27日

MIT

5分钟阅读

viewsIcon

11709

downloadIcon

184

用极少的内存高效发送大量数据

引言

最近,为了我正在开发的一个项目,我需要将大量数据记录到物联网设备的闪存中,然后将数据批量发送到一个 JSON REST 服务器的批量上传器,该服务器由 thingspeak.com 托管。数据量通常远超可用 RAM,这就是我首先将其存储在闪存日志中的原因。JSON 进一步加剧了这个问题,因为虽然它很紧凑,但与二进制日志占用的空间相比,对于相同的数据来说,它要大得多。因此,需要一种解决方案,能够让我通过 HTTP 上传大量数据,而不会耗尽 RAM 并导致设备崩溃。

尽管 ESP32 设备功能强大,但只有 520kB 的 RAM 可供使用。其前代产品 ESP8266 只有可怜的 80kB 供用户使用。这为发送数据留下的空间不多。任何非微小尺寸的动态内容都必须被流式传输,而不是在发送之前加载到内存中。这给传统的 HTTP 传输带来了一些问题,因为需要 `Content-Length` 标头,而 `Content-Length` 标头本身是由于 TCP 的工作方式而必需的。为了解决这个问题,人们设计了分块传输编码。它是 HTTP 之上的一种传输协议层,以小单元或“块”发送数据,每个块都有已知长度。这避免了对 `Content-Length` 标头的需求,并允许通过 HTTP 流式传输动态生成的内容。

不幸的是,ESP32 和 ESP8266 的核心库不包含分块传输支持。如果我们确实需要,就必须自己实现。在本文中,我们将这样做。

值得注意的是,虽然此代码主要针对 ESP 系列,但稍作修改后,几乎可以与任何符合 Arduino 标准的 SoC 设备配合使用。

必备组件

  • 装有适用于您硬件的相应板管理器
  • ESP32 开发板,或者可能是 ESP8266 板,尽管后者未经测试
  • ESPDateTime

概念化这个混乱的局面

正如我所说,分块传输编码本质上是 HTTP 之上的一个协议层。您需要 `Transfer-Encoding: chunked` 标头,而不是 `Content-Length` 标头。这将信号告知接收方,他们应该以块的形式接收数据。

同时,每个块只是一个十六进制值,指定块的长度,后跟回车符和换行符,然后是指定长度的数据,以及另一个回车符/换行符组合。最后一个块只是一个零长度块。块大小值不包括回车符或换行符——仅包括有效负载本身。下面是一个示例

5
Hello
7
 World!
0

这会将“Hello World!”发送到接收方。这就是我们如何减少内存需求并允许流式传输动态生成内容的方式。现在,我们只需要 RAM 中一次容纳一个块的空间,而不是整个文档。

编写这个混乱的程序

首先,在我们开始编写代码之前,在 C++ 中我将使用类,但我经常偏爱过程式代码来处理简单的事情,包括这个项目。如果您愿意,可以将此代码的面向对象实现留给您,亲爱的读者。

我们将涵盖根据前面描述的代码来发出一个块

// write an HTTP chunked fragment to the network client
void httpWriteChunked(const char* sz) {
  int cl = (sz) ? strlen(sz) : 0;
  if (0 < cl) {
    Serial.print(sz);
    char szt[1024];
    sprintf(szt, "%x\r\n%s\r\n", cl, sz);
    _client.print(szt);
  } else {
    Serial.println(F("<chunk terminator>"));
    _client.print("0\r\n\r\n");
  }
}

只要您熟悉 printf/sprintf 格式化字符串,这都相当直接。基本上,我们检查空字符串,然后获取字符串长度(如果为空则为 0),然后将其写出,后跟 CRLF,再后跟数据,再后跟另一个 CRLF。如果这是最后一个块(传递了空字符串或空字符串),我们会通过写入 0 后跟两个 CRLF 来指示。

现在我们来认真的。`setup()` 例程基本上完成了所有其他工作,而且工作量相当大。

void setup() {
  Serial.begin(115200);
  WiFi.begin(SSID,PASSWORD); 
  for(int i = 0;i<30 && WL_CONNECTED!=WiFi.status();++i) {
    delay(500);
  }
  if(WL_CONNECTED!=WiFi.status()) {
    Serial.println(F("Could not connect to WiFi network"));
    while(true);
  }
  // get the current time from the NTP server
  DateTime.setServer(NTP_SERVER);
  DateTime.begin();
  if (!DateTime.isTimeValid()) {
    Serial.println("Could not fetch Internet time");
    while(true);
  }

  long int dnow = DateTime.utcTime();

  if (!_client.connect(REST_SERVER, 80))
  {
    Serial.println(F("Could not connect to server"));
    while(true);  
  }
  
  char sz[1536]; // 1.5kb
  
  // build the request
  sprintf_P(sz, PSTR("POST %S HTTP/1.1\r\nHost: %S\r\n"), REST_PATH, REST_SERVER);
  strcat_P(sz, PSTR("Accept: application/json\r\n"));
  strcat_P(sz, PSTR("Content-Type: application/json\r\n"));
  strcat_P(sz, PSTR("Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n"));
  _client.print(sz);
  httpWriteChunked("{\"write_api_key\":\"YCKKPCFMQTDQKGHK\",\"updates\":["); 
  for(int i = 0; i < 5; ++i) {
    String str;
    // back "date" each response by 1 second for testing
    time_t tts = (time_t)(dnow);
    DateTimeClass dt(tts-(5-i));
    if (0<i)
      str = dt.format(",{\"created_at\":\"%Y-%m-%d %H:%M:%S +0000\",\"field1\":\"");
    else {
      str = dt.format("{\"created_at\":\"%Y-%m-%d %H:%M:%S +0000\",\"field1\":\"");
    }
    strcpy(sz,str.c_str());
    char szn[32];
    strcat(sz,itoa(i,szn,10));
    strcat(sz,"\"}");
    httpWriteChunked(sz);
  }
  httpWriteChunked("]}");
  httpWriteChunked(NULL); // terminator
  
  // now read the response
  String line = _client.readStringUntil('\n');
  if (0 != strncmp("HTTP/1.1 202 ", line.c_str(), 13))
  {
    Serial.println();
    Serial.println(F("HTTP request failed:"));
    Serial.println(line);    
  }
  while (_client.connected()) {
    line = _client.readStringUntil('\n');
    if (line == "\r") {
      // once we read the headers, terminate
      // we don't need the rest
      Serial.println(F("Success! Visit https://thingspeak.com/channels/1243886 for data"));
    }
  }
  _client.stop();
}

初始化串行端口后,我们首先开始连接到 WiFi 网络。

一旦成功,我们就需要一个时间戳来发送给 Thingspeak,但没有任何时钟可供使用,因此我们连接到互联网时间 (NTP) 服务器来获取当前时间。

接下来,我们连接到 Thingspeak 服务器并开始发送数据,从请求行和一些标头开始。这不应超过 1.5kB,所以这就是我们的缓冲区大小。总的来说,我们最终使用的比这要多一点,主要是因为日期格式化,但幅度很小。

一旦我们写完标头,我们就发送第一个块,它是我们要发送到服务器的 JSON 数据集的开头。它基本上是一种前导符,主要用于承载标记为主有效负载的 API 密钥。

接下来,我们在循环中写入 5 个条目,使用分块传输编码一次发送 1 个条目。这样,我们就不需要超过 1.5kB 的内存,这是本次演示的关键。1.5kB 有点任意。您应该选择一个平衡块大小(越大越好)和内存要求的数值。

请注意,在每个条目中,我们都在制造一个时间戳。我们为当前时间和前 4 秒创建时间戳。这是因为 Thingspeak 最多每秒显示一个数据点(每个字段),所以我们让服务器认为我们是在 5 秒前开始创建这些条目的。

现在我们发送最后的 JSON 终止序列和空块终止符。

之后,我们可以开始读取我们的响应。老实说,除了 HTTP 状态行之外,我们对响应并不关心,因为在这种情况下,状态行告诉了我们所有需要知道的信息——操作是否成功。请注意,有一个我尚未解决的 bug,有时它会报告请求失败,但实际上它成功了,因为它读取了一个空行而不是状态行。我还不知道为什么会这样,但这对于本次演示来说并不重要。

就这么简单。如果您运行的是 HTTP 服务器而不是客户端,也可以使用相同的方法发送数据。编码愉快!

Bug

目前,代码有时会在预期的 HTTP 状态行处读取一个空行,因此它会报告失败,而实际上请求可能已成功。我没有深入研究这一点,因为它与分块传输编码本身关系不大。

历史

  • 2020 年 11 月 26 日 - 初始提交
© . All rights reserved.