普段遣いの言語がpythonとgoの人がrubyを使いたくなるときのこと

備忘録ということでメモしておくことにする。

いつrubyが欲しくなるか?

今現在では日常的に書く言語、つまるところの普段遣いの言語がpythonとgoなのだけれど、時折rubyが欲しくなる。

それがいつかというと、ワンライナーがほしいとき。タイトルを見れば想像がつく人もいるかもしれない。

より厳密に言うなら、「行をパースして値を取り出す際に、興味の対象となる箇所が2箇所以上のワンライナーが欲しくなったとき」にrubyが欲しくなる。

perlでもおそらく良いのだろうけれど、手持ちの道具箱の中にperlは入っていなかったのでrubyを例にあげる(実はプログラミングを真面目に始めたときの最初の言語がrubyだった。そしてperlには触れずに済んで生きてきたのでperlをコピペ以外で使った経験がない)。

ワンライナーを使う上での前提

自分自身の持つ道具箱から、いつどういうタイミングでどの道具を取り出すか?ということを整理するために、例題ベースでその対応を考えてみることにする。つまりrubyに至るまでの道筋をもう少し丁寧にメモしておくことにする。

基本的には、シェルスクリプトで済む範囲で生活しようしているようだ。一方で、原理主義ではないので、あまり複雑な機能を使おうとすることもないし。真面目に丁寧に使うこともない気がする。基本的な機能だけで生活している。もっと言えば、ループと条件分岐ですらなるべく使わない範囲で生活しようとしている。

いたるところで使われるgrep

ワンライナーの基本はgrepなような気がする。そしてwindowsではこの体験がし辛いので苦労していたことがあった。

(powershellが使いづらいという話は、結構いろいろ話せることがある気がする。すごく雑に言うと、表現や状態やアクションの数が複数ということは中を覗かなくてはいけない、この機能とパイプでつなぎ合わせる動作が衝突するというような話。また別の機会に愚痴のような形でメモにするかもしれない)

普通のgrep

行中から特定のパターンに合致する行を取り出すときにはgrepを使う。

例えば以下の様な内容のファイルから、箇条書きに対応する行を取り出してみる。

00sentence.txt

# section

- foo
- bar
- boo

箇条書きは、-で始まる行なので^-grepする。正規表現を使うときには概ね常に-Pオプションを付ける。 (他に-を含んだ行がなさそうな場合には、雑に grep '-' でごまかすこともある)

$ cat 00sentence.txt | grep -P "^-"
- foo
- bar
- boo

特定のファイル名のものだけを対象に、なにか処理を行いたい、と言うときには、findなどと組み合わせる事がある。 例えばテストファイルだけを取り出したいときには、findだけで済むが、そのうち特定のファイル名のみからなるファイルだけが欲しかった場合には、grepで絞り込む。

$ find . -name "test*.py"

# ファイル名にfooを含むようなもののみ。
$ find . -name "test*.py" | grep foo

xargsやループ

このようにして集めたファイル名の一覧に対して処理をしたい場合には xargsを使うか、forループを回す、この方法のforループだけをシェル中の構文として利用を許している。先程はループも使わないなどと言ってしまったが。

例えば、gofmtやblackのようなフォーマッターを掛けることや、lintを掛けること、もしくはsedなどで正規表現にマッチした部分を書き換えること。wcなどで行数を数えたりといった処理をよく後段につなげる。

基本的にはxargsで済ませられる分にはxargs派なのだけれど、forループの方がechoなどで途中の状態を確認したりなどの試行錯誤がしやすい事がある。またバッククォートを使った形式はネストした呼び出しができないが、Makefile中で利用する文にはMakeの構文と衝突しづらいので時折便利。

# xargsでsedをつなげてooを@@に変換する。
$ find . -name "test*.py" | grep foo | xargs -I{} sed -i 's/oo/@@/g'

# あるいは以下のように
$ for i in `find . -name "test*.py" | grep foo`; do echo $i; done
$ for i in $(find . -name "test*.py" | grep foo); do echo $i; done

-lオプション

grepでは-lオプションもよく使う。これは逆に特定のパターンを含んだファイル名を返す。-rオプションを込みで利用して再帰的に探索してfindの代わりとして使うこともある。あるいはgit grep -lを同様のものとして使うこともある。

# fooをimportしているpythonファイルだけを集める。 (from fooには対応していない)
$ grep -rP "import.*foo" .

# 集めたファイルをformatに掛ける
$ grep -rP "import.*foo" . | xargs gofmt -i

-oオプション

マッチした部分だけがほしい場合には-oオプションを使う。例えば特定の形式のパターンで抽出した部分に対してカウントしたい場合に、uniqとsortを込みで使う。

例えば、gitで更新があったファイルに対して、部分的にformatterをあてるというような使い方をすることもある。この場合にはgit diff --name-statusの結果などをparseする。

$ git diff --name-status
D   heh
M   daily/20200314/example_ast/q.py
M   daily/20200315/example_pygraphql/Makefile
M   daily/20200315/example_pygraphql/requirements.txt
M   daily/20200315/readme.md

$ git diff --name-status | grep -P "^(M|A)" | grep -P -o "[^/]+$"
q.py
Makefile
requirements.txt
readme.md

# 拡張子で集計するだとか
$ git diff --name-status | grep -P "^(M|A)" | grep -P -o "[^/]+$" | grep -P -o "\..+$" | sort | uniq -c
   1 .md
   1 .py
   1 .txt

# あるいはsedで無理やり潰す場合もある
$ git diff --name-status | grep -P "^(M|A)" | sed "s@.*/@@g"| grep -P -o "\..+$" | sort | uniq -c
   1 .md
   1 .py
   1 .txt

-h

複数のファイルを対象にした際に、-lの逆が欲しい場合もある。そのときには-hオプションを使うこともある。

cut, sed, grep

興味の対象が1箇所だけの場合にはcutとsedを組み合わせる事が多い。

$ ps
  PID TTY           TIME CMD
  403 ttys000    0:00.07 -bash
 1073 ttys000    0:07.91 screen
16317 ttys001    0:00.00 pbcopy
66189 ttys001    0:09.14 bash
 5852 ttys002    0:00.06 /usr/local/bin/bash --noediting -i
 5857 ttys003    0:28.95 /opt/local/Library/Frameworks/Python.framework/Versions/3.8/Resources/Python.app/Contents/MacOS/Python $HOME/.emacs.d/.python-environments/default/bin/jediepcserver --virtual-env $HOME/vboxshare/venvs/my/
 5859 ttys003    0:02.49 /opt/local/Library/Frameworks/Python.framework/Versions/3.8/Resources/Python.app/Contents/MacOS/Python $HOME/emacs-sandbox/emacs.d/.python-environments/default/lib/python3.8/site-packages/jedi/inference/compiled/subprocess/__main__.py $HOME/emacs-sandbox/emacs.d/.python-environments/default/lib/python3.8/site-packages 3.8.1
17968 ttys007    0:17.31 bash
36914 ttys008    0:03.66 bash
59247 ttys011    0:00.06 bash

# bashのprocess idだけを集める
$ ps | grep bash | cut -d " " -f 1 | grep -v "^$"
66189
16343
56094
17968
36914
59247

ruby

ようやくここで本題。

先程のcutの例で他にprocessの生存時間の情報も欲しいときなどにrubyを使う。つまりprocess idと生存時間の2箇所の値を取り扱いたくなったとき(2は2以上)。

ここで、冒頭の表現を思い出すと、rubyが欲しくなるのは、「行をパースして値を取り出す際に、興味の対象となる箇所が2箇所以上のワンライナーが欲しくなったとき」、ということでこれが対応する。ようやく伏線が回収された。

使うのは -ne正規表現正規表現と文字列の式展開が組み合わさってとても便利。

$ ps | grep bash | ruby -ne 'puts "pid:#{$1}\ttime:#{$2}" if $_ =~ /^\s*(\d+)\s+\S+\s+(\S+)/'
pid:403 time:0:00.07
pid:66189   time:0:09.17
pid:5852    time:0:00.15
pid:16612   time:0:00.00
pid:56094   time:0:08.27
pid:17968   time:0:17.31
pid:36914   time:0:03.66
pid:59247   time:0:00.06

また、パスのような /で区切られたよう文字列を持つもに対しては、%r!<pattern>!みたいな感じで正規表現リテラルを変えて記述できるので、バックスラッシュの数を節約できて便利。

$ git diff --name-status
M   daily/20200314/example_ast/q.py
M   daily/20200315/example_pygraphql/Makefile
M   daily/20200315/example_pygraphql/requirements.txt
M   daily/20200315/readme.md

# status付きでexampleだけを取り出す
$ git diff --name-status | grep example | ruby -ne 'puts "status:#{$1}  path:#{$2}" if $_ =~ %r!^([A-Z]).+/(example_.+)!'
status:M    path:example_ast/q.py
status:M    path:example_pygraphql/Makefile
status:M    path:example_pygraphql/requirements.txt

こういうときにrubyを使う。竜頭蛇尾っぽいけれど。メモなので。備忘録なので。おしまい。

追記: awkは。。?

awkを使っても良いと思います。

graphqlのdataloader的なもの(bulk query)について考えてみる

そろそろノルマがやばいのでdataloderを支えるような概念について考えてみることにする。

例えば以下のようなコードは3回queryを実行する。

u0 = Users.get(1)
u1 = Users.get(2)
u2 = Users.get(3)

実際のアプリケーションでは、これらのqueryがコード上の到るところに散らばっているようなイメージ。通常はN+1になる。これを一回(あるいは定数回)だけのqueryにしたい。

やりたいことは基本的にはN+1の除去なのだけれど、嬉しいのは(特徴的なのは)、時間的にaggregateしているところ。このおかげでコード上では遠くにあるようなcomponentごとの通信をinterceptした形で、素直なコードになりつつ、N+1を除去したような形で取り扱うことができる。

そしてこの種のgroupingを行うためには非同期処理の機構が整っているとやりやすい。

ただ、特にgraphqlで特別というわけではなく、古くはbackboneあたりのSPAの頃から、promiseをcacheする機構を用意したりなどして自前で実装している人はいたりした。

仕組み

この種の機構の肝になっているのは「時間的にaggregateしている」ということ。

例えばレイヤーをHTTP(HTTPS)の通信に持っていくとしたら、同様のrequestをまとめるmiddlewareが間に挟まった状態と考える感じ。そしてrequestを一定時間バッファリングし、同一条件でのqueryをまとめてbulk queryとしてbackendに投げて、結果を分割した形で返す。

というふうに考えると、複数のクライアントのrequestを考慮したaggregateになる(フロントエンドだけのアーキテクチャとみるのではなく、インフラ用のアーキテクチャと見る事もできる)。つまりいたるるところで同様の仕組みを考えることができたりする。

実装自体はfutureのような機構があればたやすい。基本的には以下の機構があれば良い。

  • future的な機能
  • 取得対象のidを一覧で受けて返すquery(in query)

futureを使ったbulk query

簡単な実験用のコードを書いてみる。usersというテーブルが有り、ここでnameがprimary key(id)だとする。そして、in queryが実装されている。find_all()in_オプションにidとなる値を渡してあげると良い。

(minidbはこの場の実験コードを書くためのちょっとしたラッパー。実装は後述)

また、冒頭のgetに似たようなfind_one()というqueryも実装している。

import minidb
import asyncio


class Users(minidb.AsyncTable):
    pk = "name"


users = Users([{"name": "foo"}, {"name": "bar"}, {"name": "boo"}])


async def run():
    print(await users.find_one(in_=["foo"]))
    # {'name': 'foo'}

    print(await users.find_one(in_=["boo"]))
    # {'name': 'boo'}


asyncio.run(run())

このコードに対するdata loader的な振る舞いを考えてみる。それは、find_one()のようなidを一つだけ取り値をひとつだけ返すような関数を表面上は使っているように見えるものの、内部ではfind_all()のように一度にガバっと値を取るような関数を使ったコードに書き換えて利用されることを目指したものになる。

bulk query

bulk queryの実装を考えてみよう。一定時間待ち、その間に受け取ったrequestをbufferに貯めておく、そしてfind_all()としてまとめたqueryとして投げて、結果を回収する。個別のrequestの結果を返す。

例えばこういう感じ。

async def run_bulk_query():
    bulk_fut = asyncio.Future()
    input_buf = []

    async def find_user(name: str):
        input_buf.append(name)
        for row in await bulk_fut:
            if row["name"] == name:
                return row

    async def do_bulk(n):
        await asyncio.sleep(n)
        bulk_fut.set_result(await users.find_all(in_=input_buf))

    async def do_task(name):
        print(await find_user(name))

    actions = [do_task(name) for name in ["foo", "bar", "boo"]]
    actions.append(do_bulk(0.1))
    await asyncio.gather(*actions)

場合によっては、futureをより細かい粒度で用意する場合もある。そしてこの種のbufferを持ったqueueのような機構を此処の検索の条件毎にdispatchするような機構(単純には連想配列)をもたせるとそれっぽいミドルウェアが完成する。

依存があればfuture同士をつなげていくようにしてあげれば良い。

gist

https://gist.github.com/podhmo/a8d6732958b71132c7635f94643b39ff