前言

在团体内部,往往有着多个系统,系统之间往往是一个个数据孤岛,各自互不连通,各自为政。为了打通各个系统,一方面可以通过统一认证(SSO,Single-Sing-On,单点登录);一方面,对于不太需要用户登录的系统,只需要从其中获取一些信息的系统,也可以做一个中间件去抓取并提供这些信息返回给用户。这次,我们就需要打通两个系统,要实现内部 VPN 系统的用户注册、删除用户、查询连接信息(也就是增删改),并把这些信息返回给另一个系统(比如 OA,或是其它系统/第三方调用)。我们这次就来做这样的一个中间件。

思路

我们要对接的 VPN 系统是 django-sspanel ,由于没有数据表结构,大概看了一下增删改查都涉及到多个表的变更,所以直接去操作数据库显然是不明智的,而这个系统也没有接口文档,简单抓了一下包发现请求除了登录后的 cookies ,还需要携带一个 csrf-token ,而这个 token 也不知道是怎么生成出来的,感觉可能是根据时间为基础计算的,每次登录都不一样,和之前做的每日健康打卡不一样,那个只需要一个 Authorization 认证 Code 就行,而且有效期很长。但是这个的 token 有效期就比较短,另外它的页面是在客户端渲染的,直接去抓的话是拿不到数据的,需要使用浏览器进行渲染,另外也想试试换换别的招用用看,所以这次我们就选择了纯 chromedp 模拟操作的方案,对于要获取的数据,我们使用 goQuery 来抓取。

为了对接外面别的系统,我们需要提供一个 API 接口给外面调用,这里我们使用 Gin 来做,上手也是非常容易,有了之前用 Flask 写接口和在 goDanmuku 用标准库 net/http 写接口的经验,这次很快就上手了。因为是命令行程序,一个好用的 CLI 是必不可少的,之前在做每日健康打卡的时候使用的是 flag 标准包,感觉做的并不是很优雅,这次试了一下 cli 库,感觉做的要好看不少,于是在做完之后顺带把每日健康打卡重置了一下,这是后话了。为了提高运行的效率,我们把 API 提交的数据定时批量执行,我们使用 cron 库来做定时运行,同时还需要写一个任务调度器去调度任务,防止任务同时进行。

踩的几个小坑

1、SQLite 数据库的死锁问题

SQLite 是一个非常轻量的数据库,因为它的数据是直接放在一个文件里面的,所以不能在读的时候同时进行多个写操作,否则会造成死锁。最开始开发的时候我用的是 SQLite ,后面因为这个原因换了 pgsql ,由于我们 API 的性质,很可能在读的过程中会有多个写操作(外部短期调用多个 API 接口),引入锁机制可能又会导致 API 调用缓慢,所以写了小一半还是忍痛换了数据库xiaoku

2、django-sspanel 删除用户的时间限制

经过实际测试,在面板中刚刚创建的用户是不能马上删除的,创建了 25 个用户测试,发现大部分用户(24/25)在 48 小时之后才能删除,否则面板会报 Internal Error ,所以在请求之后还需要加一个等待时间,我们通过把请求时间写入数据库,执行删除任务的时候判断一下请求时间是否超过了 48 小时来解决这个问题,对于小部分不能删除的,我们打上特殊标记后继续重试,直到删除完成

3、Chromedp 的超时问题

Chromedp 没有正常执行完任务的窗口是不会自动关闭的,如果放着不管,系统的内存占用很快就会上来,最开始没有注意到这个问题,直到调试的时候发现系统 32G 的内存只剩不到 500M 的时候才意识到不对劲,虽然中间件只占用了 3.2% 的 CPU 和 15M 的内存,但是后台的上百个 Chrome 窗口消灭掉了大量的内存。要解决这个问题要从三个方面入手:

  • Chromedp 新建窗口时加上超时时间,超时自动关闭,如下
// create context
options := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true)) // debug(false)|prod(true)
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), options...)
defer cancel()
ctx, _ := chromedp.NewContext(
allocCtx,
)
// 添加超时时间
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
defer cancel()
  • 流程中用 goQuery 抓取请求结果,判断执行超过或异常
  • 任务调度时防止某个任务多次执行(见下面第4点)

4、在 Gin 中使用 Cron 时的任务抢占问题

我们的几个任务(新增用户、新增邀请码、删除用户)是后台批量执行的,我们使用 Gin 套 Cron 运行的方式,代码如下:

router := gin.Default()
vpnHelperRouter.GinRouter(router)

// 内部定时任务对象
c := cron.New(cron.WithSeconds())
// 给对象增加定时任务
c.AddFunc("* */5 * * * *", func() {
subFunction.TaskDispatcher(PanelWebAddress, PanelWebPort, PanelWebUsername, PanelWebPassword, UserEmailDomain,VpnHelperDBAddress, VpnHelperDBPort, VpnHelperDBUsername, VpnHelperDBPassword, VpnHelperDBName)
})
c.Start()

log.Println("API 服务已启动,端口:", VpnHelperWebPort)
router.Run(":" + VpnHelperWebPort)

这样会导致在定时内的每一秒都会有多个线程去执行 TaskDispatcher() 方法,所以我们一定要判断当前是否有任务在运行,如果有的话我们就不能继续执行,否则会导致任务重复执行,且同时创建巨量 Chrome 窗口,快速消耗完服务器的内存

那种事不要啊_mika

判断的方法很简单,在某流程运行时在文件夹下加一个 tag ,并在 tag 内加上一个时间戳,一定时间内不允许再次执行,执行完成后移除该 tag,超过一定时间则判断是异常退出或是执行异常或是可以继续执行新一轮任务,这样就可以解决任务调度的问题和任务抢占问题

乐_mika

5、goQuery 的使用

goQuery 其实和 JQuery 基本上一样,所以查找资料的时候,去搜索 JQ 的方法即可,明白这一点之后就简单多了

Chromedp 到指定页面 -> 输出 HTML 页面 -> goQuery 分析该 HTML 内容 -> 获取到想要的数据

一些小收获

  • 第一次用 Gin 框架,对 Gin 有了些许了解
  • Gin 下用 context.ShouldBind(&user) 检查数据,比 net/http 包简单不少,可以很方便的验证授权 code
  • 了解到了 SQLite 的死锁机制
  • 第一次尝试 cli 、 cron 和 goQuery 三个包,非常好用
  • 第一次用 Chromedp + goQuery 组合拳,非常强大好用
  • 第一次写任务调度、异常退出处理机制,非常有意思
  • 第一次用类 mvc 的方式写程序,组织了程序内部包,很有成就感。另外在静态文件里面留了个小彩蛋,挺好玩

结语

在开发这个中间件的过程中,虽然踩了不少坑,但是还是非常好玩的,写完之后非常有成就感,虽然我主要的研究方向是运维方向的,不过偶尔写写程序也很有意思,能够有效的调动大脑思考。利用这次写这个中间件的机会使用了很多之前没有用过的东西,感觉眼界也有所开阔。不过作为初学者,可能多多少少还有一些理解不到位或是不正确的地方,希望各位大佬不吝赐教

项目依然还是开源,项目地址:https://github.com/luckykeeper/django-sspanel-api,求 Star ~

放一只阔耐的心奈酱结尾