Project Resource Board: a scalable webapp with Flask socketio and Vue.js

关于

我在做一个叫做 resource board的 side project, 它是一个方便团队协作中使用共享资源的web app。

本文是我在写第一个发布版本之前持续更新的记录。

updated in 2019-April-6

以单体架构先实现了可用的版本,部署在公司内网给项目使用上了,也公布一下代码:

https://github.com/littlewey/resource-board

之后我会花时间迭代下去,不过现在课余时间在做一个机器学习调优的项目,这个优先级只能降低。

缘起

为什么开始

在云计算研发部门搬砖半年,一直做一些很小的feature开发,连着几个月早出晚归加上牺牲周末时间的赶进度让我内心很不平静。我需要做点什么,因为本质上我热爱的是 build things,做一个proper side project 的主意在脑子里转了好久了,我决定先做一个简单的东西出来。

这个东西就是一个 resource board

学到什么

resourece borard 是我起的名字,其实就是一份在线的表格、面板,本质上用 O365 / onedrive for business 完全可以实现类似的功效。不过我还是想造这样一个轮子,涉及一下产品开发的全生命周期的从无到有的创造、学习、实践:

  • planning, prototyping
  • architecture
  • front end
  • back end
  • CI/ CD
  • maintenance
  • refactoring
    • for test
    • for scale
  • tuning

User story of resource board

resource board 主要解决异地团队协作中对共享资源使用的问题,它是一个电子化的资源使用白板。

试想一下没有资源占用白板的时候,团队里的人共享10个虚拟化数据中心,我们简称 DC ,对于它的使用分几个场景:

  • CI
    • 被CI占用,没有再触发CI调用它的人不可以干预
  • testing
    • 被非独占式的人使用中,特定的人可以并行使用它,(理想情况下)不会干扰到彼此
  • occupied
    • 被人独占使用,不可与别人共享
  • free
    • 任何人可以使用它,或者挪作其他用途

这种情况下,使用它的A同学需要站起来告诉所有人,或者群发邮件,说我要用 DC123 独占调试一个代码,有人在用么? 10分钟后,在注意到没人回复的情况下,ta就在 DC123上开始做坏事 调试ta的代码了。然而刚从厕所回来的B同学,却发现ta的环境被破坏了,半天白忙活了😢。

如果这个资源共享的工作方式之中引入了记录资源占用情况的白板,A同学只需要看一下白板上空闲的DC,发现DC125 可以使用,他可以做到:

  • 无需等待确认广播信息的回复立刻使用 DC125
  • 不会破坏任何被占用的资源环境比如 DC123

看起来比较自然。

Planning

关键点

  • 数字化白板应该是一个Wep App
  • 它需要是实时的
    • 支持面向连接的(server —> client)
  • 可扩展,可扩容
    • 后端
      • 只做api
      • 分布式,可scale up
    • 前端
      • single page client
      • websocket

简单原型

我用Balsamiq Mockups 简单做了一个原型:

  • 用卡片表示每一个资源的信息
  • 不同的section表示资源被使用的状态,或者分类
  • 提供面向资源的历史查看页面

我没有花更多时间去画更详细的原型图,只是在画这个初始的版本的过程中引导我思考我初始的数据schema,前后端最开始要做的事情,一旦雏形确定下来,我就直接进入前后端的开发,在开发中直接实现业务逻辑的进化。

这里是我截取的原型图的demo动画 😁

前端、设计

基础架子

因为我不是一个JS guy、UI design guy,我选择了适合轻量级快速实现的方案: vue.js + material design。

一年两年之前,我用:

做了一个遗留系统改造的Prove of Concept (inno-search),那个过程我尝到了设计甜头,对于我这个没有任何设计经验的人,只是使用几个基本的设计 principle 就做出来明显没有设计缺陷的东西真的很有成就感,我甚至做了一个推广视频来在公司内部电视上播放 ( promo video here: https://vimeo.com/219328622 )。

扯远了,这一次我还想使用 material design 的设计元素,来垒砌这个简单的board。

因为想尝试一下响应式的前端的开发,我选择 Vue.jsVuetify.js

  • 如果我继续丰富工具的feature,我会考虑用 vue matierial admin 堆砌更丰富的功能出来。

另外,element-ui 也是一个选择,也许我之后会用它做点什么 😝。

实时性需求

为了做到实时性,就是服务端推送实时的数据变化给连接的web客户端,我选择 Socket.IO,而不是client 去pulling,这样做的好处是:

  • WebSocket的connection的延时(ms)远比pull 的interval(s) 小,能做到 真·实时
  • 实时性要求高的pulling带来的后端的开销大于WebSocket连接

后端

还是因为我不是 一个JS guy,没有选择更native的node.js,再来,我最熟悉的是 python,于是也就没有尝鲜 golang(以后可以试试)。

好久之前我在翻pycon的视频时候,注意到Miguel老师写了 python socket-io 实现,还有和他亲自基于此的 Flask-socketio ,一直都想要研究来试试,所以我还是选择熟悉的Flask 😁。

消息队列

因为可扩展架构的需求(分布式数据库、可扩展后端):

  • 后端的 server/service instance 在分布式的情况下需要支持对所有client连接的广播
  • 考虑对数据库的写操作通过基于队列的异步call规避数据并行写入做到一致性

任务队列

还有两个个我没有提及的需求是:

  • 外部 API 周期性调用
    • 我们的board之中,resource应该可以通过周期性的call CI系统的API 获取它被CI调用的状态信息。这需要异步执行的周期性任务
  • resource booking、scheduling
    • 后续提供终端用户去预约资源未来使用的功能,需要background task去监测、维护事件的触发和相关数据的读写

终于找到机会研究使用一下 Celery 了。

生产环境部署

因为兴趣原因,选择容器化,实践一下k8s的部署。

测试

Test Driven Develop,做到尽可能高coverage的单元测试。

架构

我简单地使用keynote.app 创建了一下架构图草稿如下。

Note:

  • 通过 Nginx作为load balancer,以及外部用户的http webSocket 的tls加密解密节点,也就是说tls terminated in loadbalancer。
  • 数据库的读写操作透过message queue(MQ),这里它既是cache,又是写操作的queue,考虑使用Radis(rq)。
  • 后端为计划任务单独做一个instance,board-scheduler:
    • 用来处理resource 预约到期事件
    • 周期地通过call Celery抓取 外部CI 系统的状态
  • Celary instance处理 async call(非 cpu tense的)
    • 抓取 外部系统CI状态
    • DB call
    • socketIO broadcast

Notes during coding

这里放一些我在实现过程中的一点点注意的点。

It’s not reactive?!

因为没有看完所有Vue.js的文档,就开始搞,没有意识到Vue.js对对象和数组内部变化的track是有限制的,所以做mutation的时候不可以直接假设嘴边更改都被响应式的反应到view之中,需要用Vue.set或者splice😁。

我写到中间发现View怎么都无法按照我的预期得到更新,debug 去print值也明明更改了(vuex mutation按照预期执行了),有那么半个小时我的价值观崩坍了,各种Google都无果(大家不响应变化的原因都是实际上mutation没有执行),直到我重头看了Vue.js的文档才发现我犯了最蠢的错误….

ref:

Time zone

ref: http://momentjs.com/timezone/docs/

1
npm install moment-timezone --save

数据库中存储的时间戳是UTC时区的,在前端转换为浏览器中的local时区只需要用tz这方法

1
moment.tz.(timeString, timezoneString)

我的例子:

1
moment.tz(timeString, 'UTC').local().format('MMMM Do YYYY, h:mm:ss a')

Redis

在读这个小书,发现写的不错,有不会的概念去Google学清楚就好了。

ref: https://www.openmymind.net/2012/1/23/The-Little-Redis-Book

vue-socket.io authentication with flask-socket.io

flask-login flask-socket.io vue-socket.io 登录鉴权的实现

虽然server端实现了简单地RESTful API,用来为之后增加cli-client方便,我依然决定不通过 http request,使用flask-login 管理服务端状态,它把login信息记录在flask-socketiosession之中了。

注意: 需要给出 load_user 的定义

否则,会有如下异常:

1
2
    "No user_loader has been installed for this "
Exception: No user_loader has been installed for this LoginManager. Refer tohttps://flask-login.readthedocs.io/en/latest/#how-it-works for more info.

实现的例子如下,另外还参考flask-scoket.io 官方文档糖一下 authenticated_only 这个装饰器,真香。

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
    @login.user_loader
def load_user(user_id):
return models.Users.query.filter_by(id=user_id).first()

def authenticated_only(f):
@functools.wraps(f)
def wrapped(*args, **kwargs):
if not current_user.is_authenticated:
disconnect()
else:
return f(*args, **kwargs)
return wrapped
...
@socketio.on('login user')
def login_usr(loginData):
name, passwd = loginData['username'], loginData['passwd']
user = models.Users.query.filter_by(name=name).first()
if user is None or not user.check_password(passwd):
response = {
"success": False,
"alert_type": "error",
"alert_msg": "User not exist or invalid used password!"
}
else:
login_user(user)
response = {
"success": True,
"user": name
}
emit('loginResponse', json.dumps(response))

# socketio server handle events as below
@socketio.on('update resource')
@authenticated_only
def update_resource_server(res):
updated_res = modify_resource(app.config, res)
emit('updateResource', updated_res, broadcast=True)

登录流程

vuex之中记录一个登录状态以供前端反应到view之中,click登录触发socket.io 调用,flask-socket.io server接收登录信息,验证,通过socket.io 调用返回login response,vue-socket.io 在vuex之中的 action hook 触发变化,mutation 去更新login状态,或者发送失败 alert。

Vue-socket.io & Vuex 例子

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
export default new Vuex.Store({
state: {
resources: [],
loading: false,
histories: {},
loginInfo: {
loggedIn: false,
userName: ''
}
},
mutations: {
login (state, response) {
Vue.set(state.loginInfo, 'loggedIn', response.success)
Vue.set(state.loginInfo, 'userName', response.user)
}
},
actions: {
SOCKET_loginResponse ( {commit}, response) {
// eslint-disable-next-line
console.log('[DEBUG] SOCKET_loginResponse:\n\t' + response)
let resp = JSON.parse(response)
if (resp.success === true) {
commit('login', resp)
} else {
let data = {
type: resp.alert_type,
msg: resp.alert_msg,
dismissed: false
}
commit('alerting', data)
}
},
SOCKET_registerResponse ( {commit}, response) {
// eslint-disable-next-line
console.log('[DEBUG] SOCKET_registerResponse:\n\t' + response)
let resp = JSON.parse(response)
let data = {
type: resp.alert_type,
msg: resp.alert_msg,
dismissed: false
}
commit('login', resp)
commit('alerting', data)
},
}

Post build hook for npm build

因为要替换一下Google字体cdn到国内friendly的地方,我修改了 package.json中的 postbuild这个hook,来做cdn 的FQDN替换。

1
2
3
4
5
6
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"postbuild": "./postbuild.sh",
"lint": "vue-cli-service lint"
},

keeping updating…