cover

Use GSuite Like a PRO

sorcererxw

最近有一个基于 Google 办公套件的开发需求,目的是通过业务后端来读写 Google Suit 文档。一般来说,直接使用 Google Suit SDK 就好了,但是有很多能力限制,甚至也不能创建 Google Form。而 Google 将其大量的能力打包在了 Google App Script 当中(Google 太坏了),所以需要在开发当中引入 GAS。

GAS 介绍

为什么使用 GAS

不过既然 Google 已经提供了多套 SDK 供开发者调用,为什么还要使用 Google App Script:

  • 可以在本地使用 Javascript 或者 Typescript 开发

    Google App Script 本质上就是 Javascript,语法上没有区别,只不过 gs 运行在 GAS 的环境下,许多可以调用特定的接口。也可以使用 Typescript 在本地进行编写,在上传的时候会被自动解释成 AppsScript。

  • 接口更加丰富

    作为 GSuite 的亲儿子可以调用一些 SDK 内没有的接口,比如创建 Google Form

    不过由于环境的限制,接口使用灵活度不如其他平台的 SDK

  • 可以部署为 Web 服务

    可以将 GAS 部署为一个 Web 服务,让其他程序来调用

  • 省去了调用方麻烦的鉴权操作

    如果自己的业务服务直接调用 GAS 的 Web 接口(前提是部署的时候设置为可匿名访问),不需要再额外进行鉴权操作。

    如果担心安全问题,可以通过在 query 里面带上鉴权参数,在 GAS 业务里面再自行认证。

GAS 的局限性

  • 无法工程化

    没有 module 的概念,单个文件内的函数可以相互调用,但是不能跨文件调用。

    即使可以 tsc 将多个 typescript 文件编译成一个 js文件,但是由于这种做法无法在 top-level 暴露 doGet 和 doPost 等框架特定的方法,即使是编译出来的代码依然无法被 GAS 框架识别。

  • 没有可用的 Web 框架

    只能定义 doGet 和 doPost 两个接口,提供给 GAS 框架来调用。远程调用方只有两个接口可以进行操作

    • GET https://script.google.com/macros/s/{scriptID}/exec
    • POST https://script.google.com/macros/s/{scriptID}/exec

    具体逻辑依赖于调用的 query 来判断。另外所有返回数据需要手动指定 MimeType,否则会被以 HTML 的形式返回。

  • 无法本地调试

    因为没有 module 的概念,也就无法调用外部库,所以只有部署到 GAS 的运行环境下才可以调用 Google API。对于本地开发 Google 只提供了一个 @types/google-apps-script 方便进行自动提示。

    另外,部署上也必须要在本地 push 之后,打开网页手动部署,严重降低开发效率。

  • 同步阻塞

    所有操作,无论是操作文档,还是网络请求,都是阻塞的,需要等到返回结果才能执行下一步操作,这个特点既是缺点也是优点。

填坑

解决不能跨文件调用

多个代码文件分开管理是工程化的基础,否则维护成本会非常高,所以如果希望基于 AppsScript 开发正式项目,就需要解决跨文件调用的问题。

在 Typescript 我们一般使用 ES6 的 import module 的方式来进行跨文件调用,那么只需要将在每一个文件内把所用 import 都在顶部声明,并且保证文件之间没有循环引用,就可以计算出 module 调用拓扑图,通过复制 module 代码取代 import 的方式,就可以将这个拓扑图压缩到文件内。

// ------------
// 原始文件
// ------------

// index.ts
import * as A from './A'

function sayHello(){
	A.helloWorld()
}

// A.ts
import {foo} from './B'

export function helloWorld(){
	foo()
	console.log('Hello World!')
}

// B.ts

export function foo(){
}
// ------------
// 处理之后
// ------------

const B = (function () {
	function foo(){}
	B.foo = foo
})()

const foo = B.foo

const A = (function () {
  function helloWorld() {
		foo()
    console.log('Hello World!')
  }
  A.helloWorld = helloWorld
})();

function sayHello() {
  A.helloWorld()
}

以上是一个简单的目标效果展示,通过这种方式可以将多个文件的代码合并。不过现实情况当中还有需要情况需要考虑,罗列几个主要点:

  • 需要防止模块循环引用,需要在解释之前进行预检查,可以参考 madge
  • 需要阻止 require 的使用

现在 Google AppsScript 搭配了一个工具 clasp,可以快速进行鉴权和上传部署代码。通过这个工具进行上传代码,会自动将 JavaScript 和 TypeScript 转成 gs 文件。对 Typescript 文件,可以在转换出来的文件头部看到 Compiled using ts2gas,可以 clasp 是使用 ts2gas 进行编译 TypeScript。

去看一下 ts2gas 的实现,看到头部注释里面:

/**
 * Transpiles a TypeScript file into a valid Apps Script file.
 * @param {string} source The TypeScript source code as a string.
 * @param {ts.TranspileOptions} transpileOptions custom transpile options.
 * @see https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API
 */

可见 ts2gas 其实并没有什么神奇,只是使用 typescirpt 提供的 Compiler-API 来实现 ts 的编译。使用编译出的结果加上稍许定制的代码,就能够生成 gs 文件(前面说了,gs 其实就是 js)。

那么我们是不是也可以在 ts2gas 的基础上使编译出来的 gs 符合我们预期的效果呢?可以先看看 ts2gas 具体是如何实现的。

const ts2gas = (source: string, transpileOptions: ts.TranspileOptions = {}) => {
	transpileOptions = deepAssign({},  // safe to mutate
    defaults,  // default (overridable)
    transpileOptions,  // user override
    statics,  // statics
  );
	
	// Transpile (cf. https://www.typescriptlang.org/docs/handbook/compiler-options.html)
  const result = ts.transpileModule(source, transpileOptions);

  // # Clean up output (multiline string)
  let output = result.outputText;
  
	const pjson = require('../package.json');  // ugly hack

  // Include an exports object in all files.
  output = `// Compiled using ${pjson.name} ${pjson.version} (TypeScript ${ts.version})
var exports = exports || {};
var module = module || { exports: exports };
${output}`;

	return output;
}

上面就是 ts2gas 的核心代码,就是通过 ts 的 transpileModule 来将原来代码编译。函数允许调用方传入自己的 transpileOptions 来覆盖 defaultOption,不过还是有一个 staticsOption 来强制保证某些特性的准确性,通过调整 staticsOption 就能对如阻止使用 require 的要求进行限制,在编译时抛出错误。