【Skynet 入门实战练习】游戏模块划分 | 基础功能模块 | timer 定时器模块 | logger 日志服务模块

文章目录

    • 游戏模块
    • 基础功能模块
      • 定时器模块
      • 日志模块
      • 通用模块

游戏模块

游戏从逻辑方面可以分为下面几个模块:

  • 注册和登录
  • 网络协议
  • 数据库
  • 玩法逻辑
  • 其他通用模块

除了逻辑划分,还有几个重要的工具类模块:

  • Excel 配置导表工具
  • GM 指令
  • 测试机器人
  • 服务器打包部署工具

本节先来实现几个通用的基础功能模块。

基础功能模块

定时器模块

在什么场景下,我们会要使用到定时器?

  • 每日任务的重置,比如游戏在每天的 0 点,需要定时进行刷新
  • 登录流程的超时机制,对于长时间未通过验证的连接,需要踢客户端下线,避免占用服务端资源
  • 活动结算,在定期活动结束后,需要给所有用户发放结算奖励

服务端和客户端都可以实现定时器逻辑,一般涉及到全服玩家的定时器,需要服务器来实现,针对个人玩家的定时器可以交给客户端来实现。


skynet 中,通过 skynet.timeout(time, func) 实现定时任务,skynet 基本的时间单位是 10ms,即会在 0.01s 后执行一次 func 函数。

参考:https://github.com/cloudwu/skynet/wiki/LuaAPI

skynet.timeout(ti, func) 让框架在 ti 个单位时间后,调用 func 这个函数。这不是一个阻塞 API ,当前 coroutine 会继续向下运行,而 func 将来会在新的 coroutine 中执行。

skynet 的定时器实现的非常高效,所以一般不用太担心性能问题。不过,如果你的服务想大量使用定时器的话,可以考虑一个更好的方法:即在一个service里,尽量只使用一个 skynet.timeout ,用它来触发自己的定时事件模块。这样可以减少大量从框架发送到服务的消息数量。毕竟一个服务在同一个单位时间能处理的外部消息数量是有限的。

由此,考虑自己实现一个用于定时触发事件的定时器模块,并且不需要这么高的精度,采用以秒为单位实现定时器。

定时器模块实现架构: skynet.timeout 实现循环定时器,每秒循环一次,查看并执行这一秒对应的回调函数。定时器 id 采用自增唯一映射每个定时回调函数。注册回调函数时,会计算将要执行的秒数,存入对应的回调函数表。


基础变量以及模块初始化

local _M = {} local is_init = false   -- 标记模块是否初始化
local timer_inc_id = 1  -- 定时器的自增 ID
local cur_frame = 0     -- 当前帧,一帧对应一秒
local cur_timestamp = 0 -- 当前时间戳,运行到的秒数
local timer_size = 0    -- 定时器数量
local frame_size = 0    -- 帧数量local timer2frame = {}  -- 定时器ID 映射 帧
local frame2cbs = {}    -- 帧 映射 多个回调任务
--[[frame: {timers: {timerid: { sec, cb, args, is_repeat },timerid: { sec, cb, args, is_repeat }}, size: 1}
]]if not is_init then is_init = true -- 初始化定时器模块skynet.timeout(100, main_loop)
end return _M 
  • is_init:用于标记模块是否初始化,即生成一次循环定时器,定时每秒执行 main_loop 函数
  • timer_inc_id:定时器的唯一标识 ID,每个定时器创建,都会自增
  • cur_frame:记录当前循环是对应哪一帧,随着循环自增
  • cur_timestamp:当前循环时间戳
  • timer_sizeframe_size:维护的定时器数量和帧数量
  • timer2frame:定时器 ID 对应的帧
  • frame2cbs:帧对应的回调函数表

回调函数表的结构 frame2cbs

frame: {timers: {timerid: { sec, cb, args, is_repeat },timerid: { sec, cb, args, is_repeat }}, size: 1
}

每帧对应回调函数表,有 timerssize 两个字段,size 维护当前回调函数个数,timers 则是实际的回调函数表,以定时器 ID 映射对应的回调函数。

每个回调函数都存储 seccbargsis_repeat 四个字段,表示 sec 秒后执行 cb 函数,携带 args 参数, is_repeat 表示是否是一个循环任务。


下面看每帧执行的函数 main_loop

local function now() return skynet.time() // 1 -- 截断小数:.0
end     
-- 逐帧执行
local function main_loop()skynet.timeout(100, main_loop)cur_timestamp = now()cur_frame = cur_frame + 1-- 当前没有定时器任务if timer_size <= 0 then return end -- 当前帧对应的回调任务local cbs = frame2cbs[cur_frame]if not cbs then return end -- 当前帧的回调任务数量为0if cbs.size <= 0 then frame2cbs[cur_frame] = nil frame_size = frame_size - 1 -- 该帧执行完毕return end -- task: {sec, cb, args, is_repeat}for timerid, task in pairs(cbs.timers) do local f = task[2] local args = task[3]local ok, err = xpcall(f, traceback, unpack(args, 1, args.n))if not ok then logger.error("timer", "crontab is run in error:", err)end del_timer(timerid) -- 执行成功与否都需要删掉当前这个定时器local is_repeat = task[4]if is_repeat then local sec = task[1]init_timer(timerid, sec, f, args, is_repeat)end end -- 当前这一帧所有任务执行完,并且这一帧没有删(双重保障(del_timer)),删掉当前帧if frame2cbs[cur_frame] then frame2cbs[cur_frame] = nil frame_size = frame_size - 1end 
end 

这里在入口处,我们就立即需要执行 skynet.timeout(100, main_loop),实现循环定时,并且没有多余其他操作,保证一下秒定时的准确。

skynet.time():当前 UTC 时间(单位是秒, 精度是 ms)

主要逻辑:判断当前帧是否有任务,有则执行 frame2cbs[cur_frame].timers 回调函数表中的回调函数,执行完后进行删除和判断该回调是否是循环定时任务,是则重新创建该回调的新定时器。


再来看定时器的创建和删除逻辑

init_timer

local function init_timer(id, sec, f, args, is_repeat)-- 第一步:定时器 id 映射 帧local offset_frame = sec -- sec 帧后开始当前任务 -- 矫正帧数if now() > cur_timestamp then offset_frame = offset_frame + 1end -- 实际计算执行帧local fix_frame = cur_frame + offset_frame-- 第二步:该帧 映射 定时器任务local cbs = frame2cbs[fix_frame]if not cbs then -- 创新当前帧的任务集cbs = { timers = {}, size = 1 }frame2cbs[fix_frame] = cbs frame_size = frame_size + 1 else cbs.size = cbs.size + 1end cbs.timers[id] = {sec, f, args, is_repeat}timer2frame[id] = fix_frametimer_size = timer_size + 1if timer_size >= 500 then logger.warn("timer", "timer is too many!")end 
end 

创建定时器任务,对应需要修改 frame2cbstimer2frame 表。回调函数加入当前帧的回调表中,回调的定时器ID映射当前帧,一并维护一下定时器和帧的数量统计。

在函数的开始,我们进行了对帧的校正。保证回调任务在未来帧中执行,而不会在当前帧中继续添加任务。

del_timer

-- 删除定时器
local function del_timer(id) -- 获取定时器id 映射 帧local frame = timer2frame[id]if not frame then return end -- 获取该帧对应的任务local cbs = frame2cbs[frame]if not cbs or not cbs.timers then return end -- 如果这个帧中的定时器任务存在if cbs.timers[id] then cbs.timers[id] = nil -- 删除该定时器任务cbs.size = cbs.size - 1 -- 当前帧的任务数 -1end -- 当前删掉了这一帧的最后一个定时器任务if cbs.size == 0 then frame2cbs[frame] = nil -- 置空frame_size = frame_size - 1 -- 帧数 -1 end -- 当前定时器id对应的帧置空,且定时器数量 -1timer2frame[id] = nil timer_size = timer_size - 1
end 

删除定时器逻辑很好理解,传入定时器 ID,找到 ID 对应的帧,看该帧中是否存在这个任务,存在就删除并维护帧数和定时器数量。


接口实现:

-- 新增定时器 timer,sec 秒后执行函数 f
-- 返回定时器 ID
function _M.timeout(sec, f, ...)assert(sec > 0)timer_inc_id = timer_inc_id + 1init_timer(timer_inc_id, sec, f, pack(...), false)return timer_inc_id
end function _M.timeout_repeat(sec, f, ...) assert(sec > 0)timer_inc_id = timer_inc_id + 1init_timer(timer_inc_id, sec, f, pack(...), true)return timer_inc_id
end -- 取消定时器任务
function _M.cancel(id)del_timer(id)
end -- 检查定时器是否存在
function _M.exist(id)if timer2frame[id] then return true end return false 
end -- 获取定时器还有多久执行
function _M.get_remain(id)local frame = timer2frame[id] if frame then return frame - cur_frameend return -1
end 

完整代码:timer.lua


日志模块

日志系统一般分为 4 个等级:

  • DEBUG:调试用的日志,线上运行时屏蔽不输出
  • INFO:普通日志,线上运行时输出,流程的关键步骤都需要有 INFO 日志
  • WARN:数据异常,但不影响正常流程的时候输出
  • ERROR:数据异常,且需要人工处理的时候输出

日志服务模块配置如下:

-- log conf
logger = "log"
logservice = "snlua"
logpath = "log"
logtag = "game"
-- debug | info | warn | error 
log_level = "debug"

参考官方 wiki

logger 它决定了 skynet 内建的 skynet_error 这个 C API 将信息输出到什么文件中。如果 logger 配置为 nil ,将输出到标准输出。你可以配置一个文件名来将信息记录在特定文件中。

logservice 默认为 "logger" ,你可以配置为你定制的 log 服务(比如加上时间戳等更多信息)。可以参考 service_logger.c 来实现它。注:如果你希望用 lua 来编写这个服务,可以在这里填写 snlua ,然后在 logger 配置具体的 lua 服务的名字。在 examples 目录下,有 config.userlog 这个范例可供参考。

配置中,指定 loggerlog.lua 这个日志服务,logservicesnlua 表示这个日志服务是 lua 服务。其余的三个参数作为键值对存储在配置中,用于实现服务模块时取出使用。logpath 指定为日志存放的目录路径,logtag 指定为日志进程标识,log_level 可选四种日志级别。

这里先来看日志模块:lualib/logger.lua

local skynet = require "skynet"local loglevel = {debug = 0,info = 1,warn = 2,error = 3,
}local logger = {_level = nil,_fmt = "[%s] [%s] %s", -- [info] [label] msg_fmt2 = "[%s] [%s %s] %s", --[info] [label labeldata] msg
}local function init_log_level()if not logger._level thenlocal level = skynet.getenv "log_level"local default_level = loglevel.debuglocal valif not level or not loglevel[level] thenval = default_levelelseval = loglevel[level]endlogger._level = valend
endfunction logger.set_log_level(level)local val = loglevel.debugif level and loglevel[level] thenval = loglevel[level]endlogger._level = val
endlocal function formatmsg(loglevel, label, labeldata, args)local args_len = #argsif args_len > 0 thenfor k, v in pairs(args) dov = tostring(v)args[k] = vendargs = table.concat(args, " ")elseargs = ""endlocal msglocal fmt = logger._fmtif labeldata ~= nil thenfmt = logger._fmt2msg = string.format(fmt, loglevel, label, labeldata, args)elsemsg = string.format(fmt, loglevel, label, args)endreturn msg
end--[[
logger.debug("map", "user", 1024, "entered this map")
logger.debug2("map", 1, "user", 2048, "leaved this map")
]]
function logger.debug(label, ...)if logger._level <= loglevel.debug thenlocal args = {...}local msg = formatmsg("debug", label, nil, args)skynet.error(msg)end
end
function logger.debug2(label, labeldata, ...)if logger._level <= loglevel.debug thenlocal args = {...}local msg = formatmsg("debug", label, labeldata, args)skynet.error(msg)end
endfunction logger.info(label, ...)if logger._level <= loglevel.info thenlocal args = {...}local msg = formatmsg("info", label, nil, args)skynet.error(msg)end
end
function logger.info2(label, labeldata, ...)if logger._level <= loglevel.info thenlocal args = {...}local msg = formatmsg("info", label, labeldata, args)skynet.error(msg)end
endfunction logger.warn(label, ...)if logger._level <= loglevel.warn thenlocal args = {...}local msg = formatmsg("warn", label, nil, args)skynet.error(msg)end
end
function logger.warn2(label, labeldata, ...)if logger._level <= loglevel.warn thenlocal args = {...}local msg = formatmsg("warn", label, labeldata, args)skynet.error(msg)end
endfunction logger.error(label, ...)if logger._level <= loglevel.error thenlocal args = {...}local msg = formatmsg("error", label, nil, args, debug.traceback())skynet.error(msg)end
end
function logger.error2(label, labeldata, ...)if logger._level <= loglevel.error thenlocal args = {...}local msg = formatmsg("error", label, labeldata, args, debug.traceback())skynet.error(msg)end
endskynet.init(init_log_level)return logger

这个日志模块主要暴露的四个接口分别对应四个日志等级,并且只有当前日志等级 log_level 低于当前 API 对应的等级才可以输出。如果程序测试阶段,那么指定 debug 级,就会获得所有日志。如果程序上线指定 error 级,那么只会关注到最高级别的错误日志。

error 等级日志额外输出了调用堆栈,方便查看错误问题所在的位置。

skynet.init:若服务尚未初始化完成,则注册一个函数等服务初始化阶段再执行;若服务已经初始化完成,则立刻运行该函数。

下面再来看一下日志服务代码:service/log.lua

local skynet = require "skynet"
require "skynet.manager"
local time = require "utils.time"-- 日志目录
local logpath = skynet.getenv("logpath") or "log"
-- 日志文件名
local logtag  = skynet.getenv("logtag") or "game"
local logfilename = string.format("%s/%s.log", logpath, logtag)
local logfile = io.open(logfilename, "a+")-- 写文件
local function write_log(file, str) file:write(str, "\n")file:flush()print(str)
end -- 切割日志文件,重新打开日志
local function reopen_log() -- 下一天零点再次执行local future = time.get_next_zero() - time.get_current_sec()skynet.timeout(future * 100, reopen_log)if logfile then logfile:close() end local date_name = os.date("%Y%m%d%H%M%S", time.get_current_sec())local newname = string.format("%s/%s-%s.log", logpath, logtag, date_name)os.rename(logfilename, newname) -- logfilename文件内容剪切到newname文件logfile = io.open(logfilename, "a+") -- 重新持有logfilename文件
end -- 注册日志服务处理函数
skynet.register_protocol {name = "text", id = skynet.PTYPE_TEXT, unpack = skynet.tostring, dispatch = function(_, source, str)local now = time.get_current_time()str = string.format("[%08x][%s] %s", source, now, str)write_log(logfile, str)end 
}-- 捕捉sighup信号(kill -l) 执行安全关服逻辑
skynet.register_protocol {name = "SYSTEM", id = skynet.PTYPE_SYSTEM, unpack = function(...) return ... end,dispatch = function()-- 执行必要服务的安全退出操作skynet.sleep(100)skynet.abort()end 
}local CMD = {} skynet.start(function()skynet.register(".log")skynet.dispatch("lua", function(_, _, cmd, ...)local f = CMD[cmd]if f then skynet.ret(skynet.pack(f(...)))else skynet.error(string.format("invalid command: [%s]", cmd))endend)local ok, msg = pcall(reopen_log)if not ok then print(msg)end 
end)

日志服务已经在配置中指定,logger = "log"、logservice = "snlua",不需要自行启动这个日志服务。且项目中所有 skynet.error API 输出的内容都被定向到了日志文件中,而不是输出在控制台。便于调试,write_log 写日志函数,最后调用了 print 打印日志到了终端。

通过注册 skynet.PTYPE_TEXT 文本类型消息,那么项目中的 skynet.error 输出的日志都会经过本日志服务进行分发处理,由此在分发函数 dispatch = function(_, source, str) end 中处理日志消息,对所有的日志消息进行格式化的美观输出。

日志服务工作原理可以参考文章:https://www.jianshu.com/p/351ac2cfd98c/ ,本系列 skynet 偏原理性的东西不做深入讲解。

在这里插入图片描述

日志服务如果不做切割,全部放在一个文件中会导致日志文件日益增大,这里实现 reopen_log 函数,通过 skynet.timeout 定时每天零点对日志进行切割,包括在服务重启时,也会对上次的日志文件 game.log 进行分割处理。

在这里插入图片描述

日志服务还注册了一种消息类型,skynet.PTYPE_SYSTEM,用来接收 kill -1 命令的信号,触发保存数据的逻辑,待后续实现了缓存模块在完善。


通用模块

同样在本章节,继续实现几个通用模块,细心的小伙伴应该注意到了,在实现日志模块、日志服务时,都有导入 utils.time 这个处理时间的一个模块。

下图是目前的模块 lualib 文件夹的结构:

在这里插入图片描述

  • time.lua
local skynet = require "skynet"local _M = {}-- 一秒只转一次时间戳
local last_sec 
local current_str  -- 获取当前时间戳
function _M.get_current_sec()return math.floor(skynet.time())
end-- 获取下一天零点的时间戳
function _M.get_next_zero(cur_time, zero_point)zero_point = zero_point or 0 cur_time = cur_time or _M.get_current_sec() local t = os.date("*t", cur_time) if t.hour >= zero_point then t = os.date("*t", cur_time + 24 * 3600) end local zero_date = {year = t.year, month = t.month, day = t.day, hour = zero_point,min = 0, sec = 0,}return os.time(zero_date)
end -- 获取当前可视化时间
function _M.get_current_time() local cur = _M.get_current_sec()if last_sec ~= cur then current_str = os.date("%Y-%m-%d %H:%M:%S", cur)last_sec = cur endreturn current_str
endreturn _M 

目前实现了三个接口:

  • get_current_sec:获取当前时间戳
  • get_current_time:获取当前可视化时间
  • get_next_zero:获取下一天零点时间戳,可以自定义项目的刷新时间 zero_point

其中有一个小优化是 last_sec,current_str 设置上一秒时间戳,与当前可视化时间变量,保证一秒只会转换一次。

os.time、os.date 的使用参考 lua 手册


  • table.lua
local string = require "string"local _M = {}function _M.dump(t) local print_r_cache = {}local function sub_print_table(t, indent)if (print_r_cache[tostring(t)]) thenprint(indent .. "*" .. tostring(t))elseprint_r_cache[tostring(t)] = trueif (type(t) == "table") thenfor pos, val in pairs(t) doif (type(val) == "table") thenprint(indent .. "[" .. pos .. "] => " .. tostring(t) .. " {")sub_print_table(val, indent .. string.rep(" ", string.len(pos) + 8))print(indent .. string.rep(" ", string.len(pos) + 6) .. "}")elseif (type(val) == "string") thenprint(indent .. "[" .. pos .. '] => "' .. val .. '"')elseprint(indent .. "[" .. pos .. "] => " .. tostring(val))endendelseprint(indent .. tostring(t))endendendif (type(t) == "table") thenprint(tostring(t) .. " {")sub_print_table(t, "  ")print("}")elsesub_print_table(t, "  ")endprint()
endreturn _M 
  • string.lua
local string = require "string"local _M = {}function _M.split(str, sep) local arr = {}local i = 1for s in string.gmatch(str, "([^" .. sep .. "]+)") doarr[i] = si = i + 1endreturn arr
endreturn _M 

目前 table 模块仅实现了 dump 接口,对表的美化输出, string 模块仅实现了对字符串的分割转表,有需求在自定义添加更多的功能。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/212957.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

微服务学习(十二):安装Minio

微服务学习&#xff08;十二&#xff09;&#xff1a;安装Minio 一、简介 MinIO 是一款基于Go语言发开的高性能、分布式的对象存储系统。客户端支持Java,Net,Python,Javacript, Golang语言。MinIO系统&#xff0c;非常适合于存储大容量非结构化的数据&#xff0c;例如图片、视…

VR直播如何打破视角壁垒,提升观看体验?

随着数字技术的不断发展&#xff0c;直播行业也发生了新的变革&#xff0c;VR直播也成为了直播行业中新的趋势&#xff0c;那么VR直播是如何打破视角壁垒&#xff0c;提升观看体验的呢&#xff1f; 杭州亚运会那几天&#xff0c;多项比赛热火朝天&#xff0c;无论你是参赛队伍的…

第十五届蓝桥杯(Web 应用开发)模拟赛 1 期-大学组(详细分析解答)

目录 1.动态的Tab栏 1.1 题目要求 1.2 题目分析 1.3 源代码 2.地球环游 2.1 题目要求 2.2 题目分析 2.3 源代码 3.迷惑的this 3.1 题目要求 3.2 题目分析 3.3 源代码 4.魔法失灵了 4.1 题目要求 4.2 题目分析 4.3 源代码 5.燃烧你的卡路里 5.1 题目要求 5.2…

读像火箭科学家一样思考笔记07_探月思维

1. 挑战“不可能”的科学与企业 1.1. 互联网 1.1.1. 和电网一样具有革命性&#xff0c;一旦你插上电源&#xff0c;就能让自己的生活充满活力 1.1.2. 互联网的接入可以帮助人们摆脱贫困&#xff0c;拯救生命 1.1.3. 互联网还可以提供与天气相关的信息 1.2. 用廉价、可靠的…

CCC联盟——UWB MAC(一)

本文在前面已经介绍了相关UWB的PHY之后&#xff0c;重点介绍数字钥匙&#xff08;Digital Key&#xff09;中关于MAC层的相关实现规范。由于MAC层相应涉及内容比较多&#xff0c;本文首先从介绍UWB MAC的整体框架&#xff0c;后续陆续介绍相关的网络、协议等内容。 1、UWB MAC架…

常见树种(贵州省):013桉树、米槠、栲类

摘要&#xff1a;本专栏树种介绍图片来源于PPBC中国植物图像库&#xff08;下附网址&#xff09;&#xff0c;本文整理仅做交流学习使用&#xff0c;同时便于查找&#xff0c;如有侵权请联系删除。 图片网址&#xff1a;PPBC中国植物图像库——最大的植物分类图片库 一、桉树 …

【鸿蒙应用ArkTS开发系列】- 云开发入门实战三 实现省市地区联动地址选择器组件(下)

文章目录 概述端云调用流程端侧集成AGC SDK端侧省市地区联动的地址选择器组件开发创建省市数据模型创建省市地区视图UI子组件创建页面UI视图Page文件 打包测试总结 概述 我们在前面的课程&#xff0c;对云开发的入门做了介绍&#xff0c;以及使用一个省市地区联动的地址选择器…

【OJ比赛日历】快周末了,不来一场比赛吗? #11.25-12.01 #17场

CompHub[1] 实时聚合多平台的数据类(Kaggle、天池…)和OJ类(Leetcode、牛客…&#xff09;比赛。本账号会推送最新的比赛消息&#xff0c;欢迎关注&#xff01; 以下信息仅供参考&#xff0c;以比赛官网为准 目录 2023-11-25&#xff08;周六&#xff09; #9场比赛2023-11-26…

从0开始学习JavaScript--JavaScript元编程

JavaScript作为一门灵活的动态语言&#xff0c;具备强大的元编程能力。元编程是一种通过操作程序自身结构的编程方式&#xff0c;使得程序能够在运行时动态地创建、修改、查询自身的结构和行为。本文将深入探讨JavaScript中元编程的各个方面&#xff0c;包括原型、反射、代理等…

thinkphp6生成PDF自动换行

composer安装 composer require tecnickcom/tcpdf 示例 use TCPDF;public function info($university,$performance,$grade,$major){//获取到当前域名$domain request()->domain();//实例化$pdf new TCPDF(P, mm, A4, true, UTF-8, false);// 设置文档信息$pdf->SetCr…

51单片机应用从零开始(八)·循环语句(for循环、while 语句、do‐while 语句)

51单片机应用从零开始&#xff08;七&#xff09;循环语句&#xff08;if语句&#xff0c;swtich语句&#xff09;-CSDN博客 目录 1. 用for 语句控制蜂鸣器鸣笛次数 2. 用while 语句控制 LED 3. 用 do‐while 语句控制 P0 口 8 位 LED 流水点亮 1. 用for 语句控制蜂鸣器鸣笛…

Python编写的爬虫为什么受欢迎?

每每回想起我当初学习python爬虫的经历&#xff0c;当初遇到的各种困难险阻至今都历历在目。即便当初道阻且长&#xff0c;穷且益坚&#xff0c;我也从来没有想过要放弃。今天我将以我个人经历&#xff0c;和大家聊一聊有关Python语音编写的爬虫的事情。谈一谈为什么最近几年py…