GAS(Google Apps Script)のハマりどころ@2022

今やってること

WebRTCのシグナリング鯖の構築を目指している。 WebRTCはP2Pなのは良いが、シグナリング鯖と言うやつが要求されるのである。こいつはP2P接続の情報(sdp)を交換するのだがそれだけなら一見大したことはない。 しかし実際に自前構築を考えるとこのシグナリング鯖というやつがが厄介でサンプルを見るとどいつもこいつもWebSocketをオススメしてくる。WebSocketは分かりやすいのだが無料で借りれる場所はない。WebSocketは余りにもリソースを食うのだ。他にもHttpStreamApiと言うのも有ったがFirefoxが100でようやく対応しているのでメジャーと言うには程遠い。そしてリソースを食う。

ストリーム API - Web API | MDN

結局の所、普通の掲示板でも良いのだ。情報交換ができれば普通の掲示板でも何の問題もない。 そこでGASだ!Googleのアカウントが有れば使える。Googleのアカウントは1IPか1Andoroidに6ヶ月に1回取れる。そうジャンク屋でGetしたスマホで取れるのである。 ここにシグナリング鯖を建てられれば、雨後の筍のように自腹で鯖を立てること無く、誰かに生殺与奪の権を握られること無くコピペでWebRTCを使える事に成る。

ハマったところ

次の点はハマった。これで3年は寝かせる羽目になった。

GASの制約
  • 使えるのはdoGetとdoPostだけ

    • まあ問題は無いんだよ。正常に動けば。ただし、エラーを起こすとCORS許可のヘッダーを返さずにエラー(ブラウザ上ではCORS許可されてないよエラー)になるので意味不明である。
    • 当然、投入したデータ起因でエラーになったら良くわからない。
  • GASのログ見えない

    • ログは取ってあるけど、GCPに課金してちょ!って言われるので泣く泣く諦めるしか無い。
    • 仕方がないのでdoPostを呼ぶ関数を中に作ってそいつ越しにデバッグをするしか無い。投入されたデータをエスパーして。
  • async/awaitは対応したけどdoGetとdoPostは別腹な
    • もう、何が起こったのか分からなかった。V8に対応したんじゃないの?ってdoGet/doPost呼び出し側がPromiseに対応してないでやんの・・
  • setTimeout?知らない子ですね。
    • これが痛い。マルチプロセスで掲示板上書きせずに書き込もうとか考えると結構待つ必要が発生するのだが、そんな用途は端から考えてない模様。まあExcelみたいなスプレッドシートを扱うんじゃ仕方ないね。
    • じゃあどうするか?それはもうwhileでコンテキストスイッチを噛ますべくDate.now()で指定時刻になっているか判定を別関数にして呼びまくるしか無い。console.log()とか無駄につけて。
  • Googleの他のサービス呼び出しは兎に角遅い

Cache Service  |  Apps Script  |  Google Developers * Cacheサービスの制約はKVSで生存時間は10分、保持レコード数は1000件、1レコードの長さはキーが250byte、データが100kbなので十分である。

サンプル

ライセンスはMITでよろ。

鯖側

Webアプリとして誰でもアクセス出来るようにデプロイ

const cache = CacheService.getUserCache();
const EXPIRE_DURATION = 1000 * 2;
const WAIT_EXPIRE_DURATION = 1000 * 20;
const parse = (event) => (!event || !event.parameter ? { cmd: null, group: null, data: null } : { group: event.parameter.group, cmd: event.parameter.cmd, data: event.parameter.data });
function sleep(sec = Math.floor(Math.random() * 800) + 200) {
    const expire = Date.now() + sec;
    const func = (ms) => ms > expire;
    return new Promise((resolve) => {
        while (!func(Date.now())) {
            console.log(`sleep: sec:${sec}/expire:${expire}/now:${Date.now()}`);
        }
        resolve();
    });
}
async function wait(key, value) {
    const ckey = `c%${key}`;
    const challeng = value + Date.now() + Math.floor(Math.random() * 1000000);
    let c = null;
    while (c !== challeng) {
        await sleep();
        cache.put(ckey, challeng);
        c = cache.get(ckey);
        cache.remove(ckey);
    }
}
async function add(key, value, now = Date.now()) {
    await wait(key, value);
    let c = cache.get(key);
    c = c ? JSON.parse(c) : { message: [], expire: now + 40000 };
    const n = [];
    for (const v of c.message) {
        if (v.expire > now) {
            n.push(v);
        }
    }
    n.push({ value, expire: now + WAIT_EXPIRE_DURATION });
    cache.remove(key);
    cache.put(key, JSON.stringify({ message: n, expire: now + 40000 }), 900);
}
function put(key, value, now = Date.now()) {
    cache.remove(key);
    cache.put(key, JSON.stringify({ message: value, expire: now + EXPIRE_DURATION }));
}
function doWait(state, expire) {
    console.log(`doWait:expire;${expire}`);
    if (expire < Date.now()) {
        state.isOver = true;
    }
}
// eslint-disable-next-line no-unused-vars
function doPost(event) {
    const out = ContentService.createTextOutput();
    out.setMimeType(ContentService.MimeType.JSON);
    try {
        const { group, cmd, data } = parse(event);
        const key = JSON.stringify([group, cmd]);
        const value = typeof data !== 'string' ? JSON.stringify(data) : data;
        if (cmd === 'wait') {
            const state = { isOver: false };
            add(key, value).then(() => {
                state.isOver = true;
            });
            const expire = Date.now() + 1000;
            while (!state.isOver) {
                doWait(state, expire);
            }
        } else {
            put(key, value);
        }
        console.log('END:doPost');
        out.setContent(JSON.stringify({ message: 'POST OK' }));
    } catch (e) {
        out.setContent(JSON.stringify({ message: 'ERROR', e: e.message, stack: e.stack }));
    }
    return out;
}
// eslint-disable-next-line no-unused-vars
function doGet(event) {
    const out = ContentService.createTextOutput(); //Mime TypeをJSONに設定
    out.setMimeType(ContentService.MimeType.JSON); //JSONテキストをセットする
    try {
        const { group, cmd } = parse(event);
        const key = cmd && group ? JSON.stringify([group, cmd]) : null;
        let value = key ? cache.get(key) : null;
        value = value ? (typeof value === 'string' ? JSON.parse(value) : value) : null;
        if (key && value && (!value.expire || value.expire < Date.now())) {
            cache.remove(key);
        }
        out.setContent(JSON.stringify({ message: key ? (value ? value.message : value) : 'GET OK' }));
    } catch (e) {
        out.setContent(JSON.stringify({ message: 'ERROR', e: e.message, stack: e.stack }));
    }
    return out;
}
ブラウザ側

これがなかなか正解が何かで往生した。結局、GASは"""正常時"""のみリダイレクトをしてCORS許可("*"なのでどこでもOK)を返す。不正時はクロスオリジン不許可アクセス遮断エラーになる。 なおPOSTも結果が取得できる。

const contentType = 'application/x-www-form-urlencoded';
    convertObjToQueryParam(data) {
        return data && typeof data === 'object'
            ? Object.keys(data)
                    .map((key) => `${key}=${encodeURIComponent(data[key])}`)
                    .join('&')
            : data;
    }
    async getTextGAS(path, data = {}) {
        const r = await fetch(`${path}?${this.convertObjToQueryParam(data)}`, {
            method: 'GET',
            redirect: 'follow',
            Accept: 'application/json',
            'Content-Type': contentType,
        });
        return await r.text();
    }
    async postToGAS(path, data) {
        const r = await fetch(`${path}`, {
            method: 'POST',
            redirect: 'follow',
            Accept: 'application/json',
            'Content-Type': contentType,
            body: `${this.convertObjToQueryParam(data)}`,
            headers: {
                'Content-Type': contentType,
            },
        });
        return await r.text();
    }
使い方
  • GASに鯖をデプロイする
  • ブラウザから以下のJSONを投げる
    • {group:"一意なグループ名",cmd:"waitの場合は待つ、他は適当",data:"sdp等のデータ"}
    • {message:"投稿データ"}を受け取る
  • WebRTCのバニラICEの儀式をこの掲示板(投稿データの生存時間は20秒に設定)の上で行う。