🎃

やはり俺のスプレッドシート管理はまちがっている。

2020/12/16に公開

はじめに

ClojureScriptでスプレッドシートを参照・更新できるものを作ってみました。 下図はEmacsからプログラムを利用しているものになります。

EmacsからClojureScriptのプログラムを呼び出すと、まだ完了していない依頼を取得してorg TODOとして表示します。 そして依頼が完了してTODOをDONEに変更すると、スプレッドシートの対応完了日に反映されます。

なぜつくったのか?

web業界で開発することになれば、エクセルとはさよならかなと思っていた自分でしたがそんなことはなかったです笑

確かに開発という作業においては必要ありませんが、システム運用となってくるとそうなりません。 開発チーム以外のグループから不具合の調査やシステム化しきれていない対応などの依頼を管理するのにはスプレッドシートが使われ、たとえば次のようなイメージです。

確かに依頼数が少ない初期段階はこの管理方法でも不都合ないのですが、 依頼件数が増えてくると自分が対応したのがどの依頼なのかどうかわからなくなったりするのもまたよくある問題です。

たとえば、難しい調査対応ではログやデータベース・仕様書・ソースコードなど複数のリソースから情報を総合して解決まで進むので あっちこっちとアプリを移動する必要がでてきます。 また調査中に別の緊急の依頼が入ったら、スイッチして完了後もとの作業に戻ったりする必要があります。

このようなマルチタスクが加速すると依頼内容を忘れてしまったり、完了したけど完了連絡を忘れていたりなど発生します。マルチタスクに立ち向かうには、扱うアプリの数を減らしたり解決までの手数を減らしたりしていく 工夫をしないと運用がまわっていかないなと感じはじめました。

そこでEmacsからスプレッドシートを参照したり、更新できるものがあれば!と思いはじめました。
ソースコードをみたり、調査用SQLを書いたり、orgでTODO管理をEmacs上で一元的に扱うことができるので改善されるだろうというわけです。

ClojureScriptとcore.async

spreadsheet apiをつかったツールは、ClojureScriptで実装しました。 JVMだと起動に時間かかってしまうこと,GraalVMも使用できるライブラリが限定的なため、Clojureでは力不足。
であればNodejs上でうごかせるClojureScriptの出番です。 jsで書けばよいのではと反論がでてきそうですが、Clojureを好きになりたいのでいいのです笑

Nodejsのspreadsheet apiサンプルコードをみてみると、callbackをつかって非同期処理が実装されることがわかります。

// ファイル読み込みの非同期処理
fs.readFile('credentials.json', (err, content) => {
  if (err) return console.log('Error loading client secret file:', err);
  // listMajorsがスプレッドシートから値を取得する関数
  authorize(JSON.parse(content), listMajors);
});

function authorize(credentials, callback) {
  const {client_secret, client_id, redirect_uris} = credentials.installed;
  const oAuth2Client = new google.auth.OAuth2(
      client_id, client_secret, redirect_uris[0]);

  // ファイル読み込みの非同期処理
  fs.readFile(TOKEN_PATH, (err, token) => {
    if (err) return getNewToken(oAuth2Client, callback);
    oAuth2Client.setCredentials(JSON.parse(token));
    // 引数で受け取ったcallbackの実行
    callback(oAuth2Client);
  });
}

詳細はこちらから見てみてください。

サンプルコード自体は小さなプログラムなため、比較的難なく理解することはできますが、 callbackが多段に使われるとコードの可読性は下がってしまうもの。 なのでなんとかしてこれを避けた実装をしてみたいわけです。

Clojureでは、 future, promise らで非同期処理があつかえますが、 シングルスレッドのClojureScriptでは扱えません。代わりに、core.asyncを使います。 core.asyncはgo言語のchannelのClojure版みたいなツールで、シングルスレッドのJavascript環境でも非同期処理をコントロールできます。

ということで本記事では、spreadsheet apiを使ってcore.asyncについて整理していこうと思います。

channelについて

core.asyncではchannelという概念が登場しますので、まずchannelとその操作について図にして整理します。

上図ではチャンネルは楕円で表現し、先端黒塗りの右向き矢印はチャンネルに値を送信すること 左向き矢印はチャンネルから値を受信することを表しております。

実装ではcore.asyncをrequireして、次のように右向き矢印は >! で、左向き矢印は <! を使います。 これらはgoブロックの中で使います。

(ns operation.core
  (:require
   [cljs.core.async :as async :refer [<! >! chan put!]])
  (:require-macros
   [cljs.core.async.macros :refer [go]]))

(def channel (chan))

; チャンネルにhiを送信
(go (>! channel "hi"))

; channelから値が受信できればプリント出力
(go (println (str "get channel value: " (<! channel))))

次に関数とchannelについて整理します。

四角は関数を表現しており、四角から伸びた左向き矢印は、向き先のchannelからの受信を待ちます。
反対に右向き矢印は向き先のchannelへの送信を表してます。 関数は入力にチャンネルを受け取りますが、そのチャンネルから値を受信できるまでは処理をブロックすることができます。 この性質をつかって非同期処理を扱うことができます。

(defn f
  [in-channel out-channel]
  (go (let [in (<! in-channel)]
        (js/setTimeout (fn [] (put! out-channel "hi")) 1000))))

ここでは in-channelから値が受信できると、 (js/setTimeout)処理がはじまります。
(js/setTimeout)で1秒待機したあとに out-channelに"hi"を送信してます。

channelの整理ができたので、次はspread sheetを参照・更新できるツールの実装について見ていきます。

実装

spreadsheetのapiを利用するには認証済みクライアント情報が必要となりますが、 認証済みのクライアントを得るには、credentialとtokenが必要となります。 credentialは[Enable the Google Sheets API]に従えばダウンロードできます。 一方、token取得はプログラムが必要なります。

token取得するプログラムはあとにして、まずはcredntialもtokenもすでにローカルに保存されている状態から始めます。

tokenがすでにある場合

credentialとtoken情報はローカルのファイルから取得しますが、Node.jsのfs.readFile関数は非同期関数なのでchannelを使っていきます。 先程のchannelモデルをつかって次のように図示できます。

read-file関数の実装は次のように書けます。

(defn read-file
  [file-ch content-ch ex-ch]
  (go (let [file (<! file-ch)]
        (.readFile fs
                   file
                   (fn [err content]
                     (if (nil? err)
                       (put! content-ch content)
                       (put! ex-ch err)))))))

(<! file-ch) でfile-chから受信できるまで処理はブロックされます。 受信できると、readFile を使ってファイルを読み込み、 その結果がエラーでないと content-ch チャンネルに読み込んだ内容を送信し、 そうでなければ ex-ch にerrorオブジェクトを送信する関数になります。

次にcredentialsから認証用のクライアントを生成する関数を用意します。

(defn make-auth-client
  [credential]
  (let [{:keys [client_secret client_id redirect_uris]} (:installed credential)
        authenticated-client (new (.-OAuth2 (.-auth google))
                                  client_id
                                  client_secret
                                  (nth redirect_uris 0))]
    authenticated-client))

googleapi というnpmモジュールを利用してインスタンスを生成しているだけです。

認証クライアントとtokenから認証済みクライアントを作成する関数は、次のように図示できます。
認証クライアントもtokenも非同期に取得するので、入出力がchannelになっています。

これを実装すると次の通り。

(defn set-token
  [auth-client-ch token-ch output-ch]
  (go (let [authenticated-client (<! auth-client-ch)
            token (<! token-ch)]
        (.setCredentials authenticated-client token)
        (>! output-ch authenticated-client))))

auth-client-ch, token-ch の両方から受信できるとtokenをセットして, output-ch に認証済みのクライアントを送信する関数になります。

これで必要な役者が揃ったので、これらをまとめた関数 authorize を作っていきます。
今までの図を組みあせて、次のような図がかけると思います。

これらをひとまとめにした関数が次のようになります。

(defn authorize
  [c t]
  (let [cred-path (chan)
        token-path (chan)
        cred (chan 1 (map (comp make-auth-cilent js->clj-key (.-parse js/JSON))))
        token (chan 1 (.-parse js/JSON))
        authorizedClient (chan)
        exception (chan 1 (map println))]

    ; channel同士を関数でくっつける
    (read-file cred-path cred exception)
    (read-file token-path token exception)
    (set-token cred token authorizedClient)

    ; ここで初めて処理がはじまる
    (go (>! cred-path c))
    (go (>! token-path t))

    ; 返り値はchannelになる
    authorizedClient))

let で必要な分のchannelを作成して、準備した関数の引数にいれます。 set-token 関数まではまだ処理は進みません。まだchannelには何も値が送られていないのでブロックされてます。 2つのgoブロックで cred-path, token-path にファイルパスが送信されると、はじめて処理が開始します。
各関数を経由してchannel同士がやり取りして、最終的にauthorizedClient channel に送信されます。。

補足ですが cred channelをつくるときに map 関数が引数に渡されてます。
この処理はチャンネルから値が受信されたときに適用されて、適用結果を受け手は取得できます。
よってset-tokencred から値を取得するときに make-auth-client が適用された結果を受け取れるようにしてます。

authorize 関数を利用する側は、返り値がchannelになってるので、
そこから認証済みクライアントが受信できるので、それを利用してapiを利用します。

返り値をchannelにする点はjavascriptのPromiseをreturnにすることだと思えばよいと思ってます。

apiを使ってシートの情報を得る

認証済みのクライアントが取得できたので次はそれを利用してシートから値を取得してみます。

(defn get-sheet
  [oAuthClient params]
  (let [ch (chan)]
    (go (let [args (clj->js {:version "v4" :auth oAuthClient})
              sheets (.sheets google args)]
          (.spreadsheets.values.get sheets
                                    (clj->js params)
                                    (fn [err content]
                                      (if (nil? err)
                                        (put! ch (get-in (js->clj content :keywordize-keys true) [:data :values]))
                                        (println err))))))
    ch))

oAuthClient を受け取って (.spreadsheets.values.get sheets) でシートから値を取得します。 どのシートのどの範囲の値を取得するには params を指定することで取得できるようにします。 非同期で取得した結果は、関数内で生成した ch channelから取得できるようにしてます。

そして最後はこれらを利用して、最終的には以下で結果が得られます。

(def spread-sheet-id "******") ; スプレッドシートのID
(def sheet-name "requests")               ; シート名
(def offset-row 2)             ; 2行目から取得(ヘッダーは取得しない)
(def start-column "A")         ; 何列目から
(def end-column "F")           ; 何列目まで

(defn main
  []
  ;; authorizeの結果が帰ってくるまでブロックしてoAuthClientにセット
  (go (let [oAuthClient (<! (authorize credential-path token-path))
            params {:spreadsheetId spread-sheet-id
                    :range (str sheet-name "!" start-column offset-row ":" end-column)}
            ;; get-sheetの結果が得られまでブロックして結果をdataにセット
            data (<! (get-sheet oAuthClient params))]

        (println data))))

<! を使って今まで用意してきた非同期関数の処理結果が得られまで待機することで得られます。 あとはこの結果をorg TODOようにformatして利用してます。

tokenが存在しない場合

では次にtokenが存在しない場合、つまりtokenを新たに取得してからapiを利用できるように考えていきます。 ここでも非同期処理が発生します。

tokenが存在する場合の説明では、 read-flie でファイルを読み込めないと exception channelに値を送りました。 これを別のチャンネル req-new-token に送信します。
そしてこのチャンネルから受信できれば、新しいtokenを取得できるようにします。

token-pathから read-file が値を受け取って、ファイルがなかったら req-new-token に値を送ってます。

そして、token取得のためワンタイムのpasscodeが必要なります。 該当URLにアクセスして、適切なアクセス許可を設定することでpasscodeが取得できます。 取得できたpasscodeは標準入力を経由してプログラムに渡していきます。

まずはユーザからのpasscode入力をうけとる関数をつくっていきます。

実装すると次になります。

(defn ask-passcode
  [req-token-ch auth-url-ch passcode-ch]
  (go (let [url (<! auth-url-ch)
            _ (<! req-token-ch)
            rl (.createInterface readline (clj->js {:input (.-stdin js/process)
                                                    :output (.-stdout js/process)}))]
        (println (str "以下のURLにアクセスして認証完了させてください:" url))
        (.question rl "ここにパスコードを入力したらEnterを押してください: "
                   (fn [code]
                     (.close rl)
                     (put! passcode-ch code))))))

readline はnpmモジュールでquestionメソッドのcallbackは、ユーザの入力が完了すると実行されます。 req-token-ch, auth-url-ch から受信できると処理開始し、 passcode取得用URLが標準出力に表示され、ユーザにパスコードを要求します。
ユーザがパスコードが入力すると、 passcode-ch に値が送信されます。

続いてpasscodeからtokenを取得する処理をみます。

実装は次の通り。

(defn make-new-token
  [client-ch passcode-ch token-ch]
  (go (let [client (<! client-ch)
            passcode (<! passcode-ch)]
        (.getToken client passcode (fn [err token]
                                         ;; 取得したtokenをローカルに保存する
                                         (write-file token-path (.stringify js/JSON token))
                                         (put! token-ch (.stringify js/JSON token)))))))

これでようやくtokenが取得できる関数たちを揃いました。 これらをつないで行きましょう。

要素が増えてきて混乱するかもしれませんが、今まで登場したきたものを組みあせただけです。 req-new-token チャンネルから受信できると、 ask-passcode, get-new-token をへて tokenチャンネルに値が送信され、認証済みのクライアントが取得できます。

残りは auth-url, tokenClient にどうやって値を送信するかで終わりです。
実は、 auth-urltokenClientmake-auth-client で作成されたインスタンスが必要です。 make-auth-client の結果をほしいchannelは、 credential に加えて auth-urltokenClinet も必要となってきたわけです。

あるチャンネルに値を送ると、それを受け取れるのは1つのチャンネルだけです。
それを複数のチャンネルが受け取れるようにするために, async/mult というものを使います。

次のように実装できます。

(let [credential (chan 1 (map (comp make-auth-client js->clj-key (.-parse js/JSON))))
      auth-url (chan 1 (map generate-url))
      tokenClient (chan)
      authClientClient (chan)
      m-auth (async/mult credential)]

  (async/tap m-auth auth-url)
  (async/tap m-auth authClient)
  (async/tap m-auth tokenClient))

async/mult の引数にチャンネルを受け取り、 async/tap でそのチャンネルから受信できた値を複数のチャンネルに送信できます。

そしてこれをつなげると最終的には次のようになります。

(defn authorize
  [c t]
  (let [credential-path (chan)
        credential (chan 1 (map (comp make-auth-client js->clj-key (.-parse js/JSON))))
        auth-url (chan 1 (map generate-url))
        req-new-auth (chan)
        token-path (chan)
        tokenClient (chan)
        token (chan 1 (map (.-parse js/JSON)))
        passcode (chan)
        authClient (chan)
        authorizedClient (chan)
        m-auth (async/mult credential)
        exception (chan)]

    (async/tap m-auth auth-url)
    (async/tap m-auth req-new-auth)
    (async/tap m-auth tmp)

    (read-file credential-path credential exception)
    (read-file token-path token req-new-token)

    (ask-passcode req-new-token auth-url passcode)
    (make-new-token tokenClient passcode token)

    (set-token authClient token authorizedClient)

    (go (>! credential-path c))
    (go (>! token-path t))

    authorizedClient))

generate-url 関数がさらっと登場してますが、clientのメソッドを利用してurl生成する関数です。

(defn- generate-url
  [authClient]
  (.generateAuthUrl authClient (clj->js {:access_type "offline" :scope SCOPES})))

以上でtokenをあたらしく取得する部分の実装も完了です。

さいごに

非同期処理を扱うためにcore.asyncをつかって以下を整理して、spreadsheet apiが使えるようになりました。

  • channelの基本操作
  • channelと関数を組みあせ方
  • マルチキャストするchannelの使い方

当初の目論見通り、callback地獄を回避したコードを実装することできました。

面白いと感じたこと

  1. ひとつひとつの関数はシンプル
  2. channelと関数を用意すれば、あとはなすがまま。

authorize はcredentailとtokenから認証済みクライアントを作成する関数で、最終的には線路のような図ができました。 それぞれの関数を切り出すと、ファイルを読み込んだり、passcodeを要求する関数となります。 そしてひとたびファイルパスが入力されると、あとは線路にしたがって勝手に処理が進んでいきます。

課題に感じたこと

  1. channelのネーミングにこまる
  2. 全体像がぱっとわかりにくい

authorize 関数をみればわかるとおり多くのチャンネルが登場してます。 それぞれに意味はあるものの名前をつけるのは結構大変だなと感じました。

整理した図ですがソースからこれがわかるのかというと思うと?なのかなと感じました。 大げさな図になって、長期的な観点から変更しやすいのかどうかを今後検討したいと思います。

ともあれClojureらしいシンプルさについて触れることできたことや、 channelと関数を組み合わせて問題を解決していく様はjavascriptにはなく新鮮でした。 まるでドミノ倒しというのかピタゴラスイッチというのか、はたまたプラレールのように思え素直に感動でした。

まだまだClojure歴が浅いので命名方法など色々気になる点があるかと思いますので、ご指摘ください。
さいごまでありがとうございました。

Discussion