下载 App
10/16案例分享
【案例分享】HTTP 请求、JSON 与服务器时间:客户端压根没有 http,白名单按整条 URL 全字符串匹配
投稿方向:星火编辑器论坛「案例分享」专区 / 生态贡献度
类型:图文案例
素材来源:engine-docs/recipes/http.md、recipes/json.md、recipes/server-time.md + 服务端脚本实测
一句话结论
星火编辑器的客户端 Lua 里没有 http 这个全局变量,所有外部请求只能在服务端发;而服务端的 URL 白名单是「整条字符串完全一致」才算过,查询参数变一个字符请求就被拦——所以变化的参数必须从 URL 里赶出去,走 POST body。
这三件事我各栽过一次。请求写在客户端,http 是 nil,报错像拼写错误。带分页参数的 URL 拼出来,白名单拦下,只给一句 error。把 os.date 当成时区无关的格式化函数,两个玩家看到的活动开始时间差了 13 小时。
一、三段路各归谁管
API:在哪一层可用 给你什么
http:Create():只有服务端 Lua 一个 HttpClient,链式配置后 Send()
cjson:两边都有 引擎内置全局变量,不用 require
common.get_server_time():只有客户端 Lua 权威 Unix 秒,整数
服务端取时间直接用 os.time()。部署平台的 NTP 会同步服务端进程时钟,它本身就是权威的,不需要再问谁。客户端的 os.time() 读的是玩家设备时钟,改一下就能穿越,只配拿去画 UI。
二、完整代码:请求 + 解析 + 重试 + 时间
先建一个小模块,把「永远不要裸调 cjson.decode」这件事封进去。
-- httpkit.lua 服务端 Lua
local HttpKit = {}
local TIMEOUT_MS = 5000
local MAX_RETRY = 2
-- cjson 遇到 UTF-8 BOM 会直接报错,先剥掉
local function stripBom(s)
if s:sub(1, 3) == "\239\187\191" then
return s:sub(4)
end
return s
end
function HttpKit.parse(text)
if type(text) ~= "string" or #text == 0 then
return nil, "empty body"
end
local ok, data = pcall(cjson.decode, stripBom(text))
if not ok then
return nil, "json decode: " .. tostring(data)
end
return data, nil
end
return HttpKit
请求本体。两条判断线分开走:OnError 管「连响应都没拿到」,response.success 管「拿到了但不是 2xx」。
-- onDone(data, err, statusCode)
function HttpKit.request(opts, onDone)
local attempt = 0
local function fire()
attempt = attempt + 1
local client = http:Create()
client:SetUrl(opts.url)
client:SetMethod(opts.method or HTTP_GET)
client:SetTimeout(opts.timeoutMs or TIMEOUT_MS)
for k, v in pairs(opts.headers or {}) do
client:AddHeader(k, v)
end
if opts.body then
client:SetContentType("application/json; charset=utf-8")
client:SetBody(opts.body)
end
client:OnSuccess(function(_, response)
-- 拿到了响应不等于成功,404 / 500 也走这条回调
if not response.success then
onDone(nil, "http " .. response.statusCode, response.statusCode)
return
end
local data, err = HttpKit.parse(response.dataAsString)
onDone(data, err, response.statusCode)
end)
client:OnError(function(_, statusCode, err)
-- DNS 失败、超时、白名单拦截,都到这里
if attempt <= MAX_RETRY and opts.retry ~= false then
fire() -- 重新 Create 一个新的 client,别复用旧的
return
end
onDone(nil, "net " .. statusCode .. " " .. err, statusCode)
end)
client:Send()
end
fire()
end
调用侧长这样,注意 URL 是写死的常量:
HttpKit.request({
url = "https://api.example.com/rank/list";, -- 白名单里的原样一条
method = HTTP_POST,
body = cjson.encode({ page = 1, size = 20 }),
}, function(data, err)
if err then
log:Write(LOG_ERROR, "rank fetch fail: " .. err)
return
end
for i = 1, #data.list do -- cjson 解出来是 1-based
print(data.list[i].nickname, data.list[i].score)
end
end)
客户端侧的权威时间,就三行:
local BJ_OFFSET = 8 * 3600
-- 时间戳转北京时间。! 强制按 UTC 解析,+8 自己加
local function toBeijing(sec)
return os.date("!%Y-%m-%d %H:%M:%S", sec + BJ_OFFSET)
end
-- 限时活动判定:开始/结束时间戳由服务端下发
local function isEventOpen(cfg)
local now = common.get_server_time()
return now >= cfg.startTs and now < cfg.endTs
end
三、超时、重试、字段类型,照这个表填
场景:SetTimeout 重试次数
轻量读 / 配置拉取:3000 ms 2
普通业务 API:5000 ms 2
大响应体(列表、回放):15000 ms 1
写操作 POST / PUT / DELETE:8000 ms 0
写操作默认不重试。你没拿到响应,不代表服务端没执行——超时的那一次很可能已经落库了,重试就是双份。
HttpResponse 的字段类型,我按拿到手的实际值列一遍:
字段:类型 说明
statusCode:integer HTTP 状态码
statusText:string "OK"、"Not Found"
success:boolean 仅 2xx 为 true
dataAsString:string 原始字节,不是 table
downloadedBytes / totalBytes:integer 配合 OnProgress 用
progress:number 0.0 – 1.0
GetHeader(name):string 取响应头
OnError(client, statusCode, error) 里的 statusCode 是整数,error 是字符串。连接失败时这个状态码不是 HTTP 语义,别拿它去 if code == 404。
四、踩过的坑
坑 1:把请求写在客户端
-- ❌ 客户端脚本里
http:Create():SetUrl("https://api.example.com/ping"
-- attempt to index a nil value (global 'http')
-- ✅ 客户端发 RemoteEvent,服务端发 HTTP,结果再推回来
客户端模式下 http、GetHttp()、HttpClient 三个一起消失。报错信息长得像变量名写错,我照着拼写检查了十分钟。
坑 2:以为 OnSuccess 就是 200
-- ❌ 404 的响应体是 {"error":"not found"},照样进 OnSuccess
-- cjson.decode 还成功了,你拿着 error 字段当 data 用
client:OnSuccess(function(_, response)
local data = cjson.decode(response.dataAsString)
player.gold = data.gold -- nil,不报错
end)
OnError 和 response.success 是两条不重叠的判断线,两条都得写。只挂 OnSuccess 的话,服务器 500 的时候你的代码会安静地走成功分支。
坑 3:Send() 有返回值
-- ❌
local resp = http:Create():SetUrl(url):Send()
print(resp.statusCode) -- Send() 返回 nil
Send() 的签名就是 ---@return nil。异步发出去,函数立刻返回。后续逻辑必须塞进回调,或者由回调再触发。
坑 4:重试写成了无限重试
-- ❌ 服务端挂了,这段每秒发两次,直到进程重启
client:OnError(function(c, code, err)
http:Create():SetUrl(url):OnError(同样的函数):Send()
end)
我在测试环境跑过一次,日志刷了四万行。重试要带计数器,封顶之后转成失败上报。
坑 5:cjson.decode 不套 pcall
-- ❌ 后端返回 HTML 错误页,直接抛异常,后面的逻辑全断
local data = cjson.decode(response.dataAsString)
-- ✅
local ok, data = pcall(cjson.decode, stripBom(response.dataAsString))
if not ok then
log:Write(LOG_ERROR, "json decode fail: " .. tostring(data))
return
end
失败时 pcall 的第二个返回值是错误信息字符串,不是数据。别顺手把它当 data 用下去。
坑 6:中文按字节截断
Lua 字符串是字节数组。#"中文昵称" 是 12,不是 4。string.sub(s, 1, 8) 会切在半个汉字上,编出非法 UTF-8,前端渲染成一个 �。
-- 按 UTF-8 字符数截断,续字节(10xxxxxx)不单独计数
local function utf8Sub(s, maxChars)
local i, n = 1, 0
while i <= #s and n < maxChars do
local b = s:byte(i)
local step = 1
if b >= 0xF0 then step = 4
elseif b >= 0xE0 then step = 3
elseif b >= 0xC0 then step = 2 end
i = i + step
n = n + 1
end
return s:sub(1, i - 1)
end
请求头也顺手对齐:SetContentType("application/json; charset=utf-8")。只写 application/json 时,某些网关按单字节编码解请求体,中文进库就是乱码。cjson.encode 吐出来的是 UTF-8,两边得说同一个话。
坑 7:os.date 忘了那个 !
-- ❌ 同一份代码,两个玩家差 13 小时
os.date("%Y-%m-%d %H:%M:%S", sec + 8 * 3600)
-- ✅
os.date("!%Y-%m-%d %H:%M:%S", sec + 8 * 3600)
不带 ! 的时候,运行环境的本地时区会在你的 +8 之上再叠一次。UTC+8 的机器算出 UTC+16,UTC-5 的机器算出 UTC+3,两个玩家看到的活动开始时间差 13 小时。
坑 8:自己缓存 offset
-- ❌ 把玩家能改的时钟又接回了判定链路
local offset = common.get_server_time() - os.time()
local now = os.time() + offset
-- ✅
local now = common.get_server_time()
get_server_time() 本身就是权威源加单调推算,不是每次打网络。你拿它减 os.time() 存成 offset,等于把设备时钟请回来了。
坑 9:拿秒戳做毫秒级去重
返回值是整数秒,没有小数位。同一秒内的两次点击拿到的是同一个数。防连点要么用 time:GetTimeSinceEpochMs(),要么按 timeStep 自己累加——那两个都是本地时钟,做去重够用,做奖励结算不够。
五、适用边界
客户端 Lua:服务端 Lua
http / GetHttp() / HttpClient:完全不可用 可用
URL 白名单:— 整条字符串完全匹配
cjson:可用 可用
common.get_server_time():权威时间 用 os.time() 就行
os.time():玩家可改 NTP 同步,可信
白名单那条展开说:
- https://api.example.com/items?page=1 和 ...?page=2 是两条独立记录。你只报了一条,第二条就发不出去。
- 域名对不上号。通配符也不认。
- 加白名单要找 TapTap 制造团队,没有自助入口。
所以 URL 得反过来设计——把会变的东西从 URL 里赶出去。分页、筛选、玩家 ID 全塞进 POST body,URL 只留固定几条。我把接口从 11 条 URL 收敛到 3 条,白名单一次报完,之后加功能不用再找人。
不能做的事,一句话收口:客户端不能直连第三方 API(http 不存在),服务端不能请求白名单外的任何地址,包括你自己临时搭的测试机。
六、可迁移的判断方法
写这类跨进程代码时,我会先问一句:「这段逻辑跑在哪个进程里,那个进程有没有这个能力?」
http 只有服务端有,common.get_server_time() 只有客户端有,cjson 两边都有,os.time() 两边都有但只有一边可信。四个问题问完,代码该放哪一层就定下来了,不用试错,也不用等运行时报错。
(完)




