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





5.00/5 (5投票s)
用极少的内存高效发送大量数据
引言
最近,为了我正在开发的一个项目,我需要将大量数据记录到物联网设备的闪存中,然后将数据批量发送到一个 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 日 - 初始提交