Golang 实战——微信公众号课程提醒系统
起因
最早开始学 Golang 已经是整整一年前了,当时就把基础语法那一块学完了,然后拿 Golang 写了点 leetcode 题。之后由于项目里一直用 Python 和 Java,Golang 这一块就搁置下来没学了。
之前寒假本来是打算学 iOS、Mac 开发这一块的,搞了两个星期,感觉暂时不想学下去了。(我想学 SwiftUI,我觉得这才够酷,但我不想拿赖以生存的老 Macbook 尝试 Catalina,Mojave 写 SwiftUI 没有及时预览,感觉没有灵魂了。)
所以就搬出 Golang 来接着学了。看完了函数、接口、并发这一块,然后就学了一些 Web 开发方面的。(这才实在,不然语言学完就只能刷 leetcode。)
学完了差不多就开学了,刚好我一直憎恶超级课程表广告的烦扰,所以就打算写一个可以自动从教务系统获取课程表、在上课前提醒的课程表项目。这个照理来说是个前端项目,但 iOS 开发这一块还没学完。本来 Android 也行,但我用 iPhone 啊。所以想了个曲线救国的方法——微信公众号开发,纯后端,拿来练习 Golang 再好不过。
由于时间、空间有限很多地方我写的不太清晰。所以在开始阅读本文之前,我建议你打开源码,对照阅读。:https://github.com/cdfmlr/CoursesNotifier。
我在这个项目中的很多地方尝试了 Golang 的“面向对象”。Go 不是一个面向对象的语言,这给写惯了 Java、Python 的我们还是带来了一些不适应的。但没关系,正如它的发明者们所说,Go 是用来构建系统的实用语言。面向对象不可否认是构建系统的强有力工具,Golang 当然会有所支持。当然,也只是有所支持,而不是真正的面向对象,我在 coding 的时候,就在一个需要多态的地方碰到了困难,最后不得不更改设计,稍微没那么优雅了。
在这篇文章中,我尝试记录我开发这个系统的整个过程、解释尽可能多的代码设计。但因为毕竟整个项目有接近3000行代码,我不可能逐一解释到位。如果你想看懂所有东西,请去 GitHub 打开这个项目的源码对照来看,我也是个初学者,写出的代码应该还是很简单的。
另外,这篇文章不是 Golang 的入门,在开始阅读前请确保你掌握了(起码是有所了解)以下技能:
- Go语言基础:A Tour of Go :全部内容;
- Go语言Web开发基础:astaxie/build-web-application-with-golang :1~7章;
- 微信公众号开发基础:微信公众号入门指引 :1、2、4节;
目标
我的目的很明确,就是做一个微信公众号系统,在上课前发个通知提醒快上课了。但我不想手动输入课程信息,不然 iDaily Corp 开发的《课程表·ClassTable》就已经很好了。
所以还需要自动从教务系统获取课程表,学校用的新教务系统是:
嗯,我研究了一下,他这个web端反爬虫还是做的不错的,可以爬,但不好爬!那我们怎么搞到课表?
还好我发现了这个项目:TLingC/QZAPI。这位大佬爬了强智的 App,抓出了这公司的 API。可以直接调用这个接口获取课表了:
这个 API 文档做的挺好,无可挑剔;但这个 API 着实很恶心,看看他返回的课表:
这就是我们“领先的教学一体化平台”——强(ruo)智教务系统!
我找不到一个合适的、不带个人感情色彩的词语来客观公正地评价这个设计。不管了,也只能将就着用了。
肿的来说,我们的系统有两方面:
- 一个是输入(I):自动从教务系统获取课表;
- 还有是输出(O):自动提醒学生上课。
接下来我们就一步一步把这个系统实现:
设计与实现
数据库
首先是数据库设计。
本来写这东西 MongoDB 用挺方便的,但这学期有数据库课嘛,肯定不学这些 NoSQL,所以还是复习一下 SQL,用一下关系型数据库。
其实这个东西挺传统啊,就是数据库书上的例子嘛,主要就三个表:
- 一个 Student 表,存学号、微信号(公众号里的openid)还有教务密码(这个可以不存的,存了还不安全,我不知道我设计的时候是怎么想的,后悔了,但懒得改😂)
- 一个 Course 表,存课程名、上课时间、教室地点、授课老师…(就是强智API返回的那些)
- 还有就是 S-C 选课关系表。
- 最后,还有一个储存当前是那个学期之类的信息的表。
来看最后设计好的表结构:
数据模型
有了数据库,我们还要在程序里使用数据库。也就要把数据库里的记录对应到程序里的结构体(Models)中。
为了方便(懒),我们直接把数据库里的东西对应过来,弄成这几个模型,里面的属性和数据库的属性一一对应(那个current太简单了,就是一个时间嘛, time.Time
直接就可以用了,不用再去封装了):·Student
- Course
- Relationship
蓝T
是结构体,下面的黄f
是属性,红f
是函数/方法
(这些图都是从 IDEA 截图出来自己随手拼的,没时间好好调,所以有点丑)
(对,没错,我用 IDEA 写 Golang,MacBook 硬盘小鸭,没办法,咱坚强的 IDEA 带上几个插件就肩负起了我家 Java、Android、Python、Go 的所有“大型“项目开发)
注意这里强智系统API请求来的课程是没有 cid
的,但我们为了唯一识别一个课程,所以在构造函数 NewCourse
里自动通过计算 Name,Teacher,Location,Begin,End,Week,When 的 md5 和得出。
有了模型,我们再实现数据操作(Data) :StudentDatabase
、CourseDatabase
、StudentCourseRelationshipDatabase
。
这几个东西实现数据库与模型的转化,提供增删改查操作。
(这里有很多方法其实都是不必要的,都是一样的操作,我只是一开始为了图方便,复制粘贴出来的)
教务API&Client
有了这些数据模型,我们就可以访问强智教务系统了。
我们先用 Golang 把【强智教务系统API文档】(TLingC/QZAPI)里我们需要用到的接口封装一下。我们需要用到的只是“课程信息”,但使用“课程信息”,又需要我们请求“登录”和“时间信息”。所以我们需要封装这三个请求。
阅读这个强智教务系统API文档,我们会发现所有请求都是类似的 GET:
GET http://jwxt.xxxx.edu.cn/app.do?method=...&...
request.header{token:'运行身份验证authUser时获取到的token,有过期机制'},
request.data{
'method':'登录/时间/课程信息等的方法名',
'...': '一些特定的参数'
...
}
所以我们可以把这种 “强智 API GET” 封装起来,做成一个 qzApiGet
函数,简化后面的工作。这个函数通过给定学校域名(就是jwxt.xxxx.edu.cn
的xxxx
,例如华电是ncepu
)、token(如果是登录不需要token就传空字符串""
)、还有解析请求结果的结构体实例res、以及一个请求参数的map(就是method那些):
func qzApiGet(school string, token string, res interface{
}, a map[string]string) error {
// Make URL
rawUrl := fmt.Sprintf("http://jwxt.%s.edu.cn/app.do", school)
Url, err := url.Parse(rawUrl)
// Add params
params := url.Values{
}
for k, v := range a {
params.Set(k, v)
}
Url.RawQuery = params.Encode()
urlPath := Url.String()
// Make Request
client := &http.Client{
}
req, err := http.NewRequest("GET", urlPath, nil)
// Add token
if token != "" {
req.Header.Add("token", token)
}
// GET and Parse Response
resp, err := client.Do(req)
if err != nil {
log.Println(err)
return err
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
err = json.Unmarshal(body, res)
// Handle Error and Return
if err != nil {
log.Println(err)
return err
}
return nil
}
有了这个 qzApiGet
,我们就可以方便地封装出我们需要的三个请求了:
这里面的个函数大同小异,我举其中一个例子就好了:
func GetCurrentTime(school, token, currDate string) (*GetCurrentTimeRespBody, error) {
resp := &GetCurrentTimeRespBody{
}
q := map[string]string{
"method": "getCurrentTime",
"currDate": currDate,
}
err := qzApiGet(school, token, resp, q)
if err != nil {
log.Println(err)
return &GetCurrentTimeRespBody{
}, err
}
return resp, nil
}
这些都是一些相当于面向对象里的 public static
的方法啊,调用起来还是不方便。
我们希望有一个 QzClient
,这个东西的实例就像我们使用 app 一样的一个客户端,给这个客户端用户名、密码他就可以登录了,然后你要课表就直接取这个实例的 Courses
属性,一切请求都在黑箱里完成。
我们把这个 Client 写出来:
。。。这乍一看,还是很可怕的。没关系,我们一个个慢慢解释。
首先说属性,
Student
:就是来用这个强智客户端的学生,token
:是该学生登录后获取的 token,CurrentXnxqId
:表示当前学年学期Id(别问我为什么这么命名,找强智公司去!),CurrentWeek
:当前周次Courses
:就是当前学期这个同学的所有课程啦,因为需要去重,所以我直接用了一个 map,key 放我们的Cid
(md5和),value 是Course
,这样就保证了数据不重复。
再来看方法:
AuthUser
:调用我们 强智 api 里的AuthUser
,登录强智系统,获取操作 token,在该 token 过期之前可以做其他操作;FetchCurrentTime
:调用 强智api 获取当前学期、周次,储存在CurrentXnxqId
、CurrentWeek
里;FetchWeekCoursesSlowly
:获取某个星期的课表,要反爬虫,所以里面放了一个Sleep,速度很慢,用 chan 去“返回”结果。FetchAllTermCourses
,对一个学期的每个周调用FetchWeekCoursesSlowly
,获取真学期的课表,并通过appendCourse
把这些课程添加到结构体的Courses
属性中。Save
:分别调用saveStudent
,saveCourses
,saveSCRelationships
把这个客户端的学生、课程、选课关系写入库!这就是我们唯一写入数据的地方!
呼——终于写完这些了,这里有点枯燥,用强智烂系统的恶心 API 嘛,没多少意思。
小结一下,到现在为止,我们有了数据模型、数据库,可以访问教务系统、从教务系统自动获取给定学生的课表,并把学生、课程、选课关系写入数据库了。
接下来就比较有意思了,我们来看课程时钟的设计。
课程时钟
啥?课程时钟?什么是课程时钟?就是说,咱们要在上课前提醒同学们上课,这就需要服务器知道现在是什么时间、上课在什么时间。也就是一个像“钟”一样的东西,不停地走,在指定的几个时间点“响”(提醒上课),所以我们把这个模块叫做课程时钟——CourseTicker。
这个 CourseTicker 的实现很简单也很直观,就像钟“滴答滴答”地走嘛。CourseTicker 需要定时启动,检测当前是不是快上课了,如果是,就提醒学生,不是就什么都不做。
在 Go 中,要实现这样的定时启动任务很方便,只需要在一个 for 无限循环中睡眠一段时间,然后启动执行任务即可,当然,我们不希望这样永不停止的任务运行在主线程中,所以用一个「匿名函数立即执行」手法把它包装起来:
go func() {
for {
// 计算下一个执行时间
now := time.Now()
next := now.Add(t.period)
// 等待到时间
timer := time.NewTimer(next.Sub(now))
<-timer.C
// 执行任务
RunTickTask()
}
}()
抽象周期运行器
上面这个方法虽然做到了不停运行、定时执行,但是我们不方便控制它的开始、结束,而且这段代码也不方便复用。所以我们考虑封装一个可以控制开始、结束,能不停运行、定时执行的 Ticker 结构体(相当于 OOP 的类):