说说之前一个 AppScript 项目中遇到的问题

今天写这么个技术文章,完全是因为我的一个AppScript项目遇到了些问题,之前一直没有想到什么解决的办法。直到今晚我又静下心来想,找到了解决方案,分享给大家。

背景

Google 的员工福利之一就是有在办公室有按摩服务。在绝大多数的办公室的人们都是使用一个内部的系统,可以在线预订按摩服务,20分钟作为单位,每个月有20分钟的免费额度。但是巴黎就非要弄个不一样的系统,不知道为什么,他们不愿意用公司统一的系统,而是一直采用手动联系的方式。就是说,每个想要按摩的人都要直接在线或者写邮件问一个管理人员,这个管理人员再负责所有的记录,收费,监督额度等等,实在是效率低下。而且像我这样内向的boy我相信有很多,不太愿意主动的和别人联系。

所以呢,我之前在巴黎上班的时候就和那个负责人联系,问问可不可以开发一个自动化的预订系统,一来是有个网页之类的界面方便预订,再来也能节约她的时间。

她当然同意啦,所以我花了些时间,用 Google Form + Google Spreadsheet + App Script 的架构把这么个东西写了出来,至于为什么用这么个架构,其实是有很多考虑的,这里就不展开了。简单地说,首先因为有个人信息,所以需要用公司内部系统;而自建网站的话,需要的后期维护成本很高,我也不可能永远负责;加上 Google Form 的 UI 很方便设计和更改,也支持 App Script。

程序不长,几百行,我也没有写 unit test,在简单的测试之后就上线了。之后得到了不少好评,但是随着使用频率的上升,也逐渐发现了些问题。

不知道 App Script 是什么?App Script 是 Google 开发的,语法非常接近 JavaScript 但是没有 DOM API 的一种语言。通常 App Script 程序被用来和 Google 的各类 API 进行交互,而 Google 的 Doc, Spreadsheet,Slide,Form 等等也支持 App Script,可以用来更改这些文档中的内容。

问题(们)

神秘的日历

先说个之前遇到的简单的问题,现在给你个年月日,number 类型,你建一个 js 的 Date 对象来储存起来传到之后的函数用。

我最开始想到的是,新建一个 Date,再把年月日改了,大概就是

// year, month, day are defined above
var startdate = new Date();
startdate.setYear(year);
startdate.setMonth(month);
startdate.setDay(day);

 

嗯,对的,这样大部分情况都没问题的,我也是这么想的,直到遇到了一个 bug,本来预约这个月的时段却加到了下个月的日历上。Debug 之后,发现问题在这:

// Suppose year, month, day are set to 2016, 9 and 27
// If today is 2017-08-31
var startdate = new Date();
// After setYear(), date will be 2016-08-31
startdate.setYear(year);
// After setMonth(), date will be 2016-09-31
startdate.setMonth(month);
// Wait a minute, 09-31? Hmm this doesn't sound right. September only has 30 days
// True, JavaScript will change this into 2016-10-01, so the date will always be valid
// even set an invalid date.
startdate.setDay(day);
// Now after setDay(), the date is officially 2016-10-27, instead of 2016-09-27

 

看懂了吗?对的,如果现在是31日,你把月份改到了一个只有30日的月份,那么 JavaScript 是不会报错的它会很智能的把日期算到一个正确的日期,同样的,如果你把日改到-2,那么它就会自动的把月份减一,日调到上个月的倒数第二天。而这就意味着,每到月底,new Date() 的日期为31,或者30 而下个月只有30或者28天的时候,程序就会有问题,新建的日期会是下个月的,而不是当月。

调换 setMonth 和 setDay 并不会改变这个bug,如果你在8月,setDay 到31的话,会自动变到09-01。

解决这个 bug 的办法很简单,就是利用直接利用 Date 的构造函数输入日期,而不是用 Date()

// new Date(<var>year</var>, <var>month</var>[, <var>date</var>[, <var>hours</var>[, <var>minutes</var>[, <var>seconds</var>[, <var>milliseconds</var>]]]]]);
var startdate = new Date(year, month, day);

更新冲突

了解这个问题需要一些背景知识。这个工具有三大部分,Form,Spreadsheet 和 App Script。

Form 是一个预先设计好的在线表格,其中大部分选项是固定的,但是可用时段的选项是可以被 App Script 更新的,这样当前面用户提交表格后,我们就用程序更新可用时段的列表。

Spreadsheet 是最主要的部分,其中首先有 Form 的原始用户输入结果;然后有所有按摩服务的时段的时间,日期和按摩师,是否已经被预约;还有一个整理后的所有用户的预约的信息,包括用户的姓名,预约时间,长度,以及手动添加的一些额外列。

App Script 拥有所有的逻辑。当用户提交 Form 之后,我们设置了一个 Trigger,这个 Trigger 触发器会执行一个函数,在这个函数里,我们首先检查用户是否已经使用了免费配额或者选择了付费,然后更新所有时段的表格,标记该时段为不可用,然后计算出剩下的长度分别为20分钟,40分钟以及60分钟的可用的时间段,更新 Form 中相关的选项,最后在记录表格内添加总结结果,在公用日历中添加一个事件并且添加用户信息这样用户就不会忘了。

嗯对的,虽然看起来要求很简单,但是实现起来也比较麻烦。

那么问题在哪呢?原来每次从用户提交表格触发函数开始执行,直到到最后一行执行完,需要好几秒的时间,因为 App Script 要反复调用 API 进行数据的读取和写入,还有一些计算要做。而如果在上一次的触发没有执行完,而又有一个用户提交表格的话,那么就会又触发一次这个函数。因为上一次的数据还没有写入,而这又不像其他更复杂的系统和语言一样可以设置进程安全的功能,就会出现连续提交的两个用户一个没有收到确认,另一个收到两次确认的情况。

在触发函数中,你是没有办法得到到底是哪个用户输入触发了程序的,所以我只能从用户原始输入那个表格中获取最后一行进行处理。而如果系统的同步做的不好的话,很可能出现问题,连续两次时间接近的触发都会使用最后的一行,而不是一个倒数第二行,一个最后一行。

总之,由于这个架构的限制,我们没有办法通过简单的技术手段实现两次触发之间的隔离。

那么怎么办呢?

一个办法是批处理,就是不是用户每次提交表格时更新表格和添加记录,而是每过一分钟执行一次,判断是否有新的输入,如果有的话,有几条就处理几条,轮流处理的话自然不会有问题。但是这个方法需要函数每分钟执行一次,如果这个时间太久的话,那么在用户选择了某个时段之后,这个时段在下次更新前仍然会显示可用,很可能出现用户无效预订的情况。

另一个办法,就是通过一个额外的表中的一个单元格作为 mutex,我们每次执行前判断 mutex 是否可用,如果可用的话,标记他为不可用,执行函数体,在执行完后再标记为可用。这是我目前偏向的解决办法,但是要注意,每次对 mutex 的修改后都要立即调用 Spreadsheet.flush() 来强制把更改刷入表格中,最小化可能出现死锁的情况。而这个方案的问题是,首先函数变得更复杂了一些,其次如果这种手动 mutex 还是出现了死锁的情况是,新的用户输入就都不能执行了,只能手动把 mutex 的那个单元格改掉才行。

也许还有其他的方法,但是考虑到这是个工作外的项目没有时间要求,我也就一直比较懒没有去处理。

 

在这里把这些想法和发现写出来分享给大家,共勉。

发表评论

电子邮件地址不会被公开。

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据