ブラックリストの100件を1万件の名簿から除去するカッコいい方法


grep -v 100回実行するのはさすがに避けたい

いきなり問題

問題
今、約1万人の会員名簿(members.txt)と、諸藩の事情によりブラックリスト入りしてしまった約100人会員名一覧(blacklist.txt)がある。
会員名簿からブラックリストに登録されている会員のレコードを全て除去した、「キレイな会員名簿」を作りなさい。

members.txtのデータフォーマット:
- スペース区切りのテキストファイル
- 1列目:会員ID、2列目:会員名
blacklist.txtのデータフォーマット:
- 1列のみで構成されたテキストファイル
- 1列目:会員名

さて、これをどう解くか?

joinコマンドを使うとスマートに解決できるよ

まさかcat members.txt | grep -v ブラックリスト会員1 | grep -v ブラックリスト会員2 | ……なんてコード書くわけにもいかない。

普通に考えつくのは、blacklist.txtをwhileループで回して、ブラックリスト会員の数だけgrep -v ブラックリスト会員nを実行するという方法だろうが、あまりにも効率が悪い。

こんな時は、joinコマンドを使うとカッコよく解けるよ、というのがこのTipsだ。

まずは、サンプルデータを作ろう

カッコよく解けることを実証するため、まずはデータを作る。

さすがに1万人分のサンプルネームを生成するのは大変だ。そこで/dev/urandomを用いた4桁の16進数を便宜上の名前ということにして、そういう会員名簿を作ってみる。

こんなふうにしてワンライナーでサクッと作ろう。

ダミーの会員リスト(members.txt)を作る
# 註) 第1列に会員ID、第2列に(便宜上の)「名前」が入ったデータを作る
$ dd if=/dev/urandom bs=1 count=20000 |
> od -A n -t x2                       |
> tr ' ' '\n'                         |
> grep -v '^$'                        |
> awk '{printf("ID%05d %s\n",NR,$0)}' > members.txt
ダミーのブラック会員リスト(blacklist.txt)を作る
# 註) ブラックリストの(便宜上の)「名前」が入ったデータを作る
$ dd if=/dev/urandom bs=1 count=200 |
> od -A n -t x2                     |
> tr ' ' '\n'                       |
> grep -v '^$'                      > blacklist.txt

解答シェルスクリプト

先に解答シェルスクリプトを記す。

このシェルスクリプトを実行すれば、ブラックリスト会員行だけキレイに取り除かれて出力される。デフォルトだと結果は画面にだーっと表示されるので、結果は適宜ファイルにリダイレクトすること。

会員名簿浄化スクリプト(members_filter.sh)
#! /bin/sh

# === joinに掛けられるようにブラックリストをソートしておく ==========
cat blacklist.txt |
sort -k 1,1       |
uniq              > sorted_bl.txt # 列構成 1:会員名(ブラック)

# === ブラックリストを左(1)、会員名簿を右(2)としてRIGHT JOINする ====
cat members.txt                                    |
# ここでの列構成 1:会員ID 2:会員名(members.txt由来)#
sort -k2,2                                         | # JOINするため、keyにする2列目で予めソート
join -1 1 -2 2 -a 2 -o 1.1,2.1,2.2 sorted_bl.txt - | # 会員名をキーにソート
# ここでの列構成 1:会員名(sorted_bl由来,あれば) 2:会員ID 3:会員名(members.txt由来)
grep '^ '                                          | # 結合失敗(=blacklistにない)行は1列目が空なのでそれのみ抽出
sed 's/^ *//'                                      | # 行頭の空文字を除去
# ここでの列構成 1:会員ID 2:会員名(members.txt由来)#
sort -k1,1                                           # 当初の順(会員ID)に並べ替える

# === 後始末 ========================================================
rm -f sorted_bl.txt
exit 0

コードの解説

最初にこのコードのアイデアを述べておこう。一言で言うなら、「会員名簿とブラックリスト、それぞれをテーブルとみなして外部結合し、結合できなかった(=ブラックリストに無かった)行だけを抽出する」である。結合しているのがjoinコマンドの部分で、抽出しているのがその次のgrepコマンドの部分だ。

joinには-aオプションを付けてあるが、これが外部結合のためには重要だ。-aの次に指定してある2というのは右表、つまりこの場合members.txtを指しており、結合に失敗した行であってもmembers.txtにある行は表示せよという意味だ。-oオプションでは出力する列の構成を指定していて、一番最初に1(=左表、つまりブラックリスト)の会員名を出力するようにしているが、結合に失敗した場合には当然ここは空になるので、行頭には列区切り文字である半角スペースが来る。つまり、そのような行だけ抽出してやればよいわけだ。

抽出したら、sedコマンド等で先頭の半角スペースを取り除き、再びsortコマンドを使って順番を入れ替えた表を会員ID順に戻してやればよい。

SQLのSELECT文っぽいでしょ?

コードや解説を読んでいて、なんかやってることがSQLっぽく思えなかっただろうか。SQLのSELECT文で同じことをするとしたら、こんな感じに書けるでしょ?

もしもSQLのSELECT文でおんなじことをするなら
SELECT
  MEM."会員ID",
  MEM."会員名"
FROM
  blacklist AS MEM
    RIGHT OUTER JOIN
  members   AS BL
    ON BL."会員名" = MEM."会員名"
WHERE
  BL."会員名" IS NOT NULL
ORDER BY
  MEM."会員ID" ASC;

何が言いたいかというと、「SQLでできることはUNIXコマンド+シェルスクリプトでも大抵できるよ」ということだ。ついでに言うと、SELECT文でデータの流れを追うと、FROM句→(RIGHT OUTER JOIN句)→WHERE句→ORDER BY句→(最初に戻って)SELECTの直後、であるが、シェルスクリプトの場合はほぼ上から下へ一直線であるのが個人的には好きだ。