从零开始做一个智能排插#5:HTTP服务器框架

上次推荐了一次服务器框架,然而我用的时候内存不足一直报错,唉,自己写一个吧。
这次我们将完成一个服务器框架,让我们能更方便的使用网页来控制nodemcu。

首先我们先想一下,我们想让服务器做些什么?

简单的服务器

让我们从上次的服务器开始说好了

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
function parseRequest(payload)
payload = urlDecode(payload)
local req = {}
local _GET = {}
local _, _, method, path, query = string.find(payload, "([A-Z]+) (.+)?(.+) HTTP")

if(method == nil) then
_, _, method, path = string.find(payload, "([A-Z]+) (.+) HTTP")
end
if(query ~= nil) then
for k, v in string.gmatch(query, "([^&#]+)=([^&#]*)&*") do
_GET[k] = v
end
end

req.method = method
req.query = _GET
req.path = path

return req
end

srv = net.createServer(net.TCP)
srv:listen(80, function(conn)
conn:on("receive", function(conn, payload)
print(payload)

local req = parseRequest(payload)

local led_status = req.query.led
if(led_status) then
gpio.write(LED, led_status == "off" and 1 or 0)
end

body = "<h1> Hello, NodeMcu.</h1>" ..
"<p>" ..
"<span>LED开关</span>" ..
"<a href='/?led=on'><input type='button' value='开'></a>" ..
"<a href='/?led=off'><input type='button' value='关'></a>" ..
"</p>"

status = "HTTP/1.1 200 OK" .. "\r\n"
type_ = "Content-Type: text/html" .. "\r\n"
length = "Content-Length: " .. string.len(body) .. "\r\n"

conn:send(status .. type_ .. length .. "\r\n" .. body)
end)
end)

之前的代码我们先是接收了一个url,进行解析之后,我们使用其中的参数来操控了led灯,并且发送了一段文本,也就是说,一个基本的服务器需要完成以下工作。

  1. 接收参数
    1. 解析参数
  2. 发送信息
    1. 整理响应头
    2. 发送文本
    3. 发送文件
  3. 操作后台

所以简单来说,我们只要完成这几个功能就好了。

接收参数

解析参数的部分,我们已经有一个parseRequest了,考虑到可能有中文,我们会收到%20%31这样的参数,我们加一个解码url的函数

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
-- url解码
function urlDecode(url)
return url:gsub('%%(%x%x)', function(x)
return string.char(tonumber(x, 16))
end)
end

function parseRequest(payload)
payload = urlDecode(payload) -- 进行url解码
local req = {}
local _GET = {}
local _, _, method, path, query = string.find(payload, "([A-Z]+) (.+)?(.+) HTTP")

if(method == nil) then
_, _, method, path = string.find(payload, "([A-Z]+) (.+) HTTP")
end
if(query ~= nil) then
for k, v in string.gmatch(query, "([^&#]+)=([^&#]*)&*") do
_GET[k] = v
end
end

req.method = method
req.query = _GET
req.path = path

return req
end

接收参数,完。

发送信息

首先我们要知道我们发送的消息具体有些什么内容

1
2
3
4
5
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 68

<html>这里是一段html代码, 或者这是一个html文件</html>

也就是说我们要发送4个信息status, type, length, content

发送文本

当我们发送的content是一段文本,我们按照之前的写法就行。
要说明的是,我们发送文本的时候最好分段发送,因为我们不能确定文本有多大,万一有几MB的大小,内存明显是不够用的。

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
-- 发送文本
-- 传入3个参数: 服务器连接, 正文, 希望改变的响应头参数
function srvSend(conn, body, o)
local status, type_, length -- 3个响应头参数
o = o or {}

status = o.status or "200 OK" -- 默认为"200 OK"
type_ = o.type or "text/html" -- 默认为"text/html"
length = string.len(body) -- 获取正文的大小

-- 连接字符串
local buf = "HTTP/1.1 " .. status .. "\r\n" ..
"Content-Type: " .. type_ .. "\r\n" ..
"Content-Length: " .. length .. "\r\n" ..
"\r\n" ..
body

local function dosend()
if buf == "" then -- 当字符串发送完毕
srvClose(conn) -- 关闭连接
else
conn:send(string.sub(buf, 1, 1024)) -- 每次发送1024个字符
buf = string.sub(buf, 1025) -- 返回一个从第1025个字符开始到结尾的字符串
end
end

dosend() -- 执行发送操作
conn:on("sent", dosend) -- 绑定sent事件,递归执行dosend直到连接关闭
end

-- 关闭连接
function srvClose(conn)
conn:on('sent', function() end)
conn:on('receive', function() end)
conn:close()
conn = nil
collectgarbage()
end

发送文件

当我们发送的是一个文件,我们不能把文件和字符串连接起来,所以要先发送响应消息再发送文件。
同样我们发送文件时也应该分段发送。

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
-- 发送文件
function srvSendFile(conn, filename, o)
local status, type_, length
o = o or {}

if not file.exists(filename) then -- 如果找不到文件则发送信息"404 Not Found"
status = "404 Not Found"
srvSend(conn, status, { status=status })
return
end

status = o.status or "200 OK"
type_ = o.type or "text/html"

file.open(filename,"r") -- 只读方式打开文件
length = file.seek("end") -- 获取文件大小
file.close() -- 关闭文件

-- 连接字符串,注意没有body部分
local header = "HTTP/1.1 " .. status .. "\r\n" ..
"Content-Type: " .. type_ .. "\r\n" ..
"Content-Length: " .. length .. "\r\n" ..
"\r\n"

local pos = 0; -- pos变量用于存储文件位置
local function dosend()
file.open(filename, "r") -- 只读方式打开文件
if(file.seek("set", pos) == nil) then -- 如果pos位置没有字符(即pos位置已大于文件长度)
srvClose(conn) -- 关闭连接
else
local buf2 = file.read(1024) -- 每次读取文件的1024个字符
conn:send(buf2) -- 发送字符
pos = pos + 1024 -- pos位置+1024
end
file.close() -- 关闭文件
end

conn:send(header) -- 发送响应头
conn:on("sent", dosend) -- 绑定sent事件,递归执行dosend直到连接关闭
end

-- 传入url的路径,发送对应的文件
function parsePath(conn, path)
local filename = ""
if path == "/" then -- 如果是根目录
filename = "index.html" -- 发送"index.html"
else
filename = string.gsub(string.sub(path, 2), "/", "_") -- nodemcu不支持文件夹,因此将`/`转为`_`
end
srvSendFile(conn, filename)
end

这样我们就可以发送index.html之类的文件了。

状态码和MIME类型

这里提供别人框架里的两个函数,可以方便的设置Status CodeContent-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
27
28
29
30
-- 传入状态码如 200
-- 返回状态码如 "200 OK"
function getStatusCode(code)
local status = {
[1] = 'Informational', [2] = 'Success', [3] = 'Redirection', [4] = 'Client Error', [5] = 'Server Error',
[200] = 'OK',
[301] = 'Moved Permanently', [302] = 'Found',
[403] = 'Forbidden', [404] = 'Not Found'
}
local msg = status[code] or status[math.floor(code / 100)] or 'Unknow'
return code .. ' ' .. msg
end

-- 传入文件名如 "index.html"
-- 返回MIME类型如 "text/html"
function getContentType(filename)
local contentTypes = {
['.css'] = 'text/css',
['.js'] = 'application/javascript',
['.html'] = 'text/html',
['.png'] = 'image/png',
['.jpg'] = 'image/jpeg'
}
for ext, type in pairs(contentTypes) do
if string.sub(filename, -string.len(ext)) == ext then
return type
end
end
return 'text/plain'
end

基本的服务器框架

有了上面的组件,我们最算完整一个基本的服务器框架了,剩下的地方很简单,几行代码就完成了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 引入刚刚的函数
function urlDecode(url)
function parseRequest(payload)
function srvClose(conn)
function srvSend(conn, body, o)
function srvSendFile(conn, filename, o)
function parsePath(conn, path)

-- 服务器部分
srv = net.createServer(net.TCP)
srv:listen(80, function(conn)
conn:on("receive", function(conn, payload)
local req = parseRequest(payload) -- 解析请求行

parsePath(conn, req.path) -- 解析路径返回页面
end)
end)

我们可以往nodemcu里放几个文件,例如index.html啦,logo.jpg啦。

完美。

操作后台

好了,现在我们到了最后的部分,还是拿LED说事吧,跟之前一样,假设我们发送的参数为led=on就点亮LED,但这次加一个条件,只有当我们的路径为/config时才会生效,也就是

1
192.168.0.100/config?led=on

相应的服务器我们就可以这样写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
srv = net.createServer(net.TCP)
srv:listen(80, function(conn)
conn:on("receive", function(conn, payload)
local req = parseRequest(payload)

if(req.path == "/config") then -- 当路径为`/config`
local led_status = req.query.led -- 获取led参数
if(led_status) then -- 存在led参数
gpio.write(LED, led_status == "off" and 1 or 0) -- 如果led参数为off则置高电平,否则置低电平
end
parsePath(conn, "/index.html") -- 发送`index.html`
else
parsePath(conn, req.path) -- 其他情况发送相应路径中的文件
end
end)
end)

相应的html的部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE HTML>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>Hello NodeMCU</title>
</head>
<body>
<h1>Hello NodeMCU!</h1>
<p>
<img src="./logo.jpg" alt="示例图片">
<span>LED开关</span>
<a href='/config?led=on'><input type='button' value='开'></a>
<a href='/config?led=off'><input type='button' value='关'></a>
</p>
</body>
</html>

当然,如果我们想要设置多个路径,实现更复杂的操作,我们可以这样写

1
2
3
4
5
6
7
8
9
if(req.path == "/config") then
blabla...
elseif(req.path == "/config2") then
blabla...
elseif(req.path == "/config3") then
blabla...
else
parsePath(conn, req.path)
end

这样就完成了后台的操作。

顺带一提,网页中推荐使用ajax,好处是可以局部刷新网页


哎呀都4月8号了,拖延症太厉害了…话说明明deadline都快到了怎么效率还是那么低,说好的deadline是第一生产力呢?