React.js & Next.jsの勉強メモ

概要

React.js & Next.js超入門 第2版 を読んでちまちま写経していたが、 firebaseのところが現行のversion9とは結構乖離が大きくなっていたので、差分をメモしておく。 (買ってから1年以上放置してたのがそもそも悪い)

差分

初期化

書籍内

import firebase from 'firebase'

const firebaseConfig = {..}
if ( firebase.apps.length == 0) {
  firebase.initializeApp(firebaseConfig)
}

現行

import { initializeApp } from 'firebase/app';

const firebaseConfig = {...}
const app = initializeApp(firebaseConfig);
export default app;

firestoreオブジェクトの取得

書籍内

import firebase from 'firebase'

const db = firebase.firestore()

現行

import { getFirestore } from 'firebase/firestore';

const db = getFirestore(app)

ドキュメントの取得(複数)

書籍内

db.collection('mydata').get().then((snapshot) => {
  snapshot.forEach((document) => {
    const doc = document.data()
    ...
  })
})

現行

import { getFirestore, collection, getDocs } from 'firebase/firestore';

const cols = collection(db, 'mydata')
getDocs(cols).then((snapshot) => {
  snapshot.forEach((document) => {
    const doc = document.data()
    ...
  })
})

ドキュメントの取得(単一)

書籍内

import firebase from 'firebase'
import { useRouter } from 'next/router'

const router = useRouter()

if ( router.query.id != undefined ) {
  db.collection('mydata').doc(router.query.id).get().then(ob => {
    const data = ob.data()
    ...
  })
}

現行

import { getFirestore, collection, doc, getDoc } from 'firebase/firestore'
import { useRouter } from 'next/router'

const router = useRouter()

if ( router.isReady && router.query.id !== undefined ) {
  const docRef = doc(db, 'mydata', router.query.id)
  getDoc(docRef).then(ob => {
    const data = ob.data()
    ...
  })
}

追加

書籍内

import firebase from 'firebase'

const db = {
  name: name,
  mail: mail,
  age: age
}

db.collection('mydata').add(ob).then(ref => {
  ...
})

現行

import { getFirestore, collection, addDoc } from "firebase/firestore"

const ob = {
  name: name,
  mail: mail,
  age: age
}

addDoc(collection(db, 'mydata'), ob).then(ref => {
  router.push('/fire')
})

削除

書籍内

import firebase from 'firebase'
import { useRouter } from 'next/router'

const router = useRouter()

db.collection('mydata').doc(router.query.id)
  .delete().then(ref => {
    ...
  })

現行

import { getFirestore, doc, getDoc } from 'firebase/firestore'
import { useRouter } from 'next/router'

const router = useRouter()

const docRef = doc(db, 'mydata', router.query.id)
deleteDoc(docRef).then(ref => {
  ...
})

検索

書籍内

import firebase from 'firebase'

// 検索対象の文字列
const name = '...'

// 完全一致
db.collection('mydata').where('name', '==', name).get()
  .then(snapshot => {
    snapshot.forEach((document) => {
      ...
    })
  })

// 前方一致
db.collection('mydata').orderBy('name').startAt(name).endAt(name + "\uf8ff")
  .then(snapshot => {
    snapshot.forEach((document) => {
      ...
    })
  })

現行

import { getFirestore, collection, query, where, orderBy, startAt, endAt, getDocs } from 'firebase/firestore/lite';

// 検索対象の文字列
const name = '...'

// 完全一致
const cols = collection(db, 'mydata')
const q = query(cols, where('name', '==', name))
getDocs(q).then(snapshot => {
  snapshot.forEach(document => {
    ...
  })
})


// 前方一致
const cols = collection(db, 'mydata')
const q = query(cols, orderBy('name'), startAt(name), endAt(name + "\uf8ff"))
getDocs(q).then(snapshot => {
  snapshot.forEach(document => {
    ...
  })
})

ちなみに startAt(name) endAt(name + "\uf8ff") は範囲指定を意味している。 \uf8ff はコードポイントの最大値であり、上記はnameに続く文字が 0x0000〜0xf8ff の範囲にあることを示している。 例えば name: hoge のとき、

  • 先頭4文字が hoge に一致し、
  • 5文字目が 0x0000〜0xf8ff の範囲にある、つまり終端や全ての文字に一致する

という条件になるらしい。

Docker + SolrCloud

概要

以下の書籍の内容をベースにDocker + Solr8でSolrCloud環境を構築した。

構成

  • ZooKeeper
  • Solr
    • シャード 1、レプリカ 2の構成で2つのSolrは同じデータを保持する前提
    • スキーマAPIで管理するmanaged-schemaではなく自前で管理するschema.xmlを使用
  • Nginx
    • 2つのSolrへリクエストを振り分けるLB
    • 片方が死んでいる場合は生きている方に流す

f:id:sota2502:20200907225046p:plain

コード

https://github.com/sota2502/japan_address

使用するデータ

郵便番号データダウンロード - 日本郵便 を使用した。 全都道府県で約12万件。 Shift-JIS → UTF-8 への変更とスキーマに合わせたヘッダーを追加している。

ken_all.csv

ZooKeeper

いきなりSolrCloudの構成を作る前にZooKeeperに慣れておくとよい。

docker-compose-zk.yml

version: '3.7'

x-zoo-service: &zoo-service
  image: zookeeper:3.5
  restart: always
  networks:
    - solr

services:
  zoo1:
    <<: *zoo-service
    container_name: zoo1
    hostname: zoo1
    ports:
      - 2181:2181
    env_file: ./zoo.env
    environment:
      ZOO_MY_ID: 1
    volumes:
      - ./data/zoo1/data:/data
      - ./data/zoo1/datalog:/datalog
  zoo2:
    <<: *zoo-service
    container_name: zoo2
    hostname: zoo2
    ports:
      - 2182:2181
    env_file: ./zoo.env
    environment:
      ZOO_MY_ID: 2
    volumes:
      - ./data/zoo2/data:/data
      - ./data/zoo2/datalog:/datalog
  zoo3:
    <<: *zoo-service
    container_name: zoo3
    hostname: zoo3
    ports:
      - 2183:2181
    env_file: ./zoo.env
    environment:
      ZOO_MY_ID: 3
    volumes:
      - ./data/zoo3/data:/data
      - ./data/zoo3/datalog:/datalog

networks:
  solr:

各々のZooKeeperのデータを永続化するため、/data,/datalog をVolumeにマウントする。これによりrestartなどをしてもデータは失われない。

ZooKeeperのクラスタを起動。

$ docker-compose -f docker-compose-zk.yml up -d

ZooKeeperのクライアントを用意する。Macであればhomebrewでインストールするのが簡単。

$ brew install zookeeper

zoo1 に接続してznodeを作成。

$ zkCli -server localhost:2181 create /hoge fuga
Connecting to localhost:2181

WATCHER::

WatchedEvent state:SyncConnected type:None path:null
Created /hoge

先ほど作ったznodeのデータを zoo2 から読み出す。

$ zkCli -server localhost:2182 get /hoge
Connecting to localhost:2182

WATCHER::

WatchedEvent state:SyncConnected type:None path:null
fuga

SolrのCollection用Configsetの作成

_default のConfigsetをベースに郵便番号を管理するためのConfigsetを作成する。これはCollectionを作るときに使う。

以下のコマンドを実行して、テンポラリなコンテナを作って、その中から _default Configsetsを取り出す。

$ docker create --name mysolr solr:8.5.2
$ docker cp mysolr:/opt/solr/server/solr/configsets/_default ./configsets
$ docker rm mysolr

この中で solrconfig.xmlschema.xml を編集していく。

特別なポイントはUUIDの設定をしているところ。 Solrのドキュメントを更新するためにはUniqueKeyが必要になる。しかし、郵便番号データにはUniqueKeyに適したものが存在しない。(同一の郵便番号の市町村が複数存在するケースがある) この制約はCSVを編集してIDを採番することでも満たすことは可能。 しかし、UUIDを採番するようにしておくほうが手間は省ける。

solrconfig.xml

差分は以下の通り。

38a39
>   <schemaFactory class="ClassicIndexSchemaFactory" />
1111c1112
<   <updateRequestProcessorChain name="add-unknown-fields-to-the-schema" default="${update.autoCreateFields:true}"
---
>   <updateRequestProcessorChain name="add-unknown-fields-to-the-schema" default="${update.autoCreateFields:false}"
1117a1119,1124
>   <updateRequestProcessorChain name="uuid" default="true">
>     <processor class="solr.UUIDUpdateProcessorFactory">
>       <str name="fieldName">uuid</str>
>     </processor>
>     <processor class="solr.RunUpdateProcessorFactory" />
>   </updateRequestProcessorChain>

<schemaFactory class="ClassicIndexSchemaFactory" />managed-schema の代わりに schema.xml を使用する場合に記述する。

add-unknown-fields-to-the-schemaUpdateRequestProcessorChain のdefault値をfalseにしておく。これはschemalessモードを使わないための設定。

uuidUpdateRequestProcessorChain はUUIDを採番するための設定。

schema.xml

_default Configsetsには schema.xml は存在しない。 これは managed-schemaschema.xml にリネームして手動編集する。 差分は以下のようになる。これは郵便番号データに対応したfieldの定義の追加と、不要なfieldの定義の削除、UniqueKeyの変更をしている。

111a112,127
>     <field name="uuid" type="uuid" multiValued="false" indexed="true" required="true" stored="true"/>
>     <field name="jis" type="string" indexed="true" required="true" stored="true"/>
>     <field name="postcode" type="string" indexed="true" required="true" stored="true"/>
>     <field name="postcode5" type="string" indexed="true" required="true" stored="true"/>
>     <field name="prefecture_kana" type="text_ja" required="true"/>
>     <field name="city_kana" type="text_ja" required="true"/>
>     <field name="town_area_kana" type="text_ja" required="true"/>
>     <field name="prefecture" type="text_ja" indexed="true" required="true" stored="true"/>
>     <field name="city" type="text_ja" indexed="true" required="true" stored="true"/>
>     <field name="town_area" type="text_ja" indexed="true" required="true" stored="true"/>
>     <field name="is_one_town_by_multi_postcode" type="boolean" required="true"/>
>     <field name="is_need_small_area_address" type="boolean" required="true"/>
>     <field name="is_chome" type="boolean" required="true"/>
>     <field name="is_multi_town_by_one_postcode" type="boolean" required="true"/>
>     <field name="updated" type="boolean" required="true"/>
>     <field name="update_reason" type="boolean" required="true"/>
113d128
<     <field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false" />
117,122d131
<     <!-- If you don't use child/nested documents, then you should remove the next two fields:  -->
<     <!-- for nested documents (minimal; points to root document) -->
<     <field name="_root_" type="string" indexed="true" stored="false" docValues="false" />
<     <!-- for nested documents (relationship tracking) -->
<     <field name="_nest_path_" type="_nest_path_" /><fieldType name="_nest_path_" class="solr.NestPathField" />
< 
169c178
<     <uniqueKey>id</uniqueKey>
---
>     <uniqueKey>uuid</uniqueKey>
1012a1022,1023
>     <fieldType name="uuid" class="solr.UUIDField" indexed="true" />
> 
1023d1033
< 

SolrCloud環境の構築

docker-composeZooKeeperSolrNginx のSolrCloud環境を構築する。

docker-compose

docker-compose.yml やその他に必要なファイルは以下のようになる。

docker-compose.yml

version: '3.7'

x-zoo-service: &zoo-service
  image: zookeeper:3.5
  restart: always
  networks:
    - solr

x-solr-service: &solr-service
  image: solr:8.5.2
  environment:
    ZK_HOST: zoo1:2181,zoo2:2181,zoo3:2181
    SOLR_JAVA_MEM: -Xms1g -Xmx1g -Xmn512m
  networks:
    - solr
  depends_on:
    - zoo1
    - zoo2
    - zoo3

services:
  zoo1:
    <<: *zoo-service
    container_name: zoo1
    hostname: zoo1
    env_file: ./zoo.env
    environment:
      ZOO_MY_ID: 1
    volumes:
      - ./data/zoo1/data:/data
      - ./data/zoo1/datalog:/datalog
  zoo2:
    <<: *zoo-service
    container_name: zoo2
    hostname: zoo2
    env_file: ./zoo.env
    environment:
      ZOO_MY_ID: 2
    volumes:
      - ./data/zoo2/data:/data
      - ./data/zoo2/datalog:/datalog
  zoo3:
    <<: *zoo-service
    container_name: zoo3
    hostname: zoo3
    env_file: ./zoo.env
    environment:
      ZOO_MY_ID: 3
    volumes:
      - ./data/zoo3/data:/data
      - ./data/zoo3/datalog:/datalog
  solr1:
    <<: *solr-service
    container_name: solr1
    volumes:
      - ./data/solr1:/var/solr
  solr2:
    <<: *solr-service
    container_name: solr2
    volumes:
      - ./data/solr2:/var/solr
  nginx:
    image: nginx:1.19.2
    container_name: nginx
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    ports:
      - 8080:80
    networks:
      - solr
    depends_on:
      - solr1
      - solr2

networks:
  solr:

Solrのデータを永続化するために /var/solr をVolumeにマウントする。

zoo.env

ZOO_SERVERS=server.1=zoo1:2888:3888;2181 server.2=zoo2:2888:3888;2181 server.3=zoo3:2888:3888;2181
ZOO_4LW_COMMANDS_WHITELIST=mntr,conf,ruok

nginx.conf

user nginx;
worker_processes  auto;

error_log /var/log/nginx/error.log warn;
pid       /var/run/nginx.pid;

events {
  worker_connections  1024;
}

http {
  upstream solr {
    server solr1:8983;
    server solr2:8983;
  }

  server {
    listen 80;
    client_max_body_size 20M;

    location / {
      proxy_pass http://solr;
    }
  }
}

コンテナを起動。

$ docker-compose up -d

起動が完了したら http://localhost:8080/solr/ にブラウザでアクセスする。SolrのWeb管理画面が表示される。 Cloud → Graph でSolrノードの繋がりが見える。

f:id:sota2502:20200908011613p:plain

ConfigsetsのUpload

[SolrのCollection用Configsetの作成] で作ったConfigsetsをアップロードする。 これは一度zipで一つにしたものをAPIで送る。

$ cd configsets/conf
$ zip -r japan_address.zip *
$ curl -X POST --header "Content-Type:application/octet-stream" \
  --data-binary @japan_address.zip \
  "http://localhost:8080/solr/admin/configs?action=UPLOAD&name=japan_address"

ZooKeeperコンテナに対して2181ポートをマッピングしておいて、 zkClils コマンドを実行するとConfigsetsに対するznodeができているのが確認できる。 (上記のdocker-compose.ymlではポートをマッピングしていないので、これを行う場合には ports の記述を追加する必要がある)

$ zkCli -server localhost:2181 ls /configsets

Collectionの作成

http://localhost:8080/solr/#/~collections:Solr管理コンソール からCollectionを作成する。 今回はシャード1、レプリカ2なので以下のように入力する。

項目
name japan_address
configset japan_address
numShards 1
replicationFactor 2

Add Collection をクリックして画面をリロードするとCollectionが作成されたのが確認できる。

CSVデータのインポート

以下のコマンドを実行してCSVデータをインポートする。これは結構時間がかかる。

$ curl -H 'Content-type: text/csv; charset=utf-8' \
  --data-binary @ken_all.csv \
  'http://localhost:8080/solr/japan_address/update?commit=true' 

検索の実行

Curlコマンドか Solr管理画面 でクエリを実行すると結果が返ってくるのが見える。

$ curl 'http://localhost:8080/solr/japan_address/select?q=*:*'

フェールオーバーの確認

現在は2つのSolrにNginxがリクエストを振り分けている状態。 ここから片方のSolrが落ちた状態でもリクエストを処理していることを確認する。

以下のコマンドで solr2 のコンテナを停止させる。

$ docker-compose stop solr2

Solr管理画面のCloud → Tree で片方が停止しているのが把握できる。

Curlで何回かクエリを実行して、いずれもレスポンスが返ってきているのが確認できる。

WWW::Curl::Easyでファイルをアップロード

目的

WWW::Curl::EasyでファイルをPOSTでアップロードしたい。 ただし、ファイルは巨大になるケースを想定して、一旦メモリに読み込むのを避けたい。

コード

use strict;
use warnings;

use WWW::Curl::Easy;

my $file = 'hoge.csv';
my $size = -s $file;

open my $fh, '<', $file or die 'Cannot open file';
my $url = 'http://example.com/sample';

my @headers = (
    "Content-type: text/csv; charset=utf8"
);

my $curl = WWW::Curl::Easy->new();
$curl->setopt(CURLOPT_POST, 1);
$curl->setopt(CURLOPT_URL, $url);
$curl->setopt(CURLOPT_POSTFIELDSIZE_LARGE, $size);
$curl->setopt(CURLOPT_READDATA, \$fh);
$curl->setopt(CURLOPT_HTTPHEADER, \@headers);

if ( $curl->perform != 0) {
    die 'fail to upload';
}

my $code = $curl->getinfo(CURLINFO_HTTP_CODE);
print "Code: $code";

close $fh;

参考

https://stackoverflow.com/questions/9485157/wwwcurl-how-to-upload-post-large-files

YAPC::Tokyo 2019 に参加した感想

とりあえず雑でも感想を書いておく。後にすると絶対書かない。

メッセージングのプロトコル

https://speakerdeck.com/lyokato/yapc-japan-tokyo-2019-metusezingufalsepurotokoru

  • 発表後の本人のMediumのエントリ を読むと発表の意図がわかりやすい。確かに、特定の技術に対して安易にマイナスなイメージをつけないようにされていて、その点はすごくよかった。
  • どうすれば標準化がうまくいって広まったんだろう?慎重になりすぎてたんだろうか?
  • スライドの文字が小さくて読みづらく、マイクが離れていたので聞きづらかったので、情報を得るのがしんどかった。。

ランチセッション Garuda:汎用コネクティビティ プラットフォーム Perlをサイエンスの世界につなぐ

  • 創薬はすごい手間も時間もかかるし、そこに非エンジニアのリソースをバンバン投入したいので、そういうところで重宝されるんだろうな。そういった用途で非エンジニア向けにはすごく需要高そう。

ランチセッション 20歳の僕が経験したPerlエンジニアへの道

  • 「やりたい」「やりたくない」だけで選ばず、やることによる理由をポジティブに捉えてるのがよかった。
  • 知らない言語の現場に放り込まれるというのはきっとあるだろうし、自分の学習プロセスを構築するのにはいい経験。

綱渡りバッチ脱出大作戦

https://speakerdeck.com/shinpeim/gang-du-ribatutituo-chu-da-zuo-zhan

  • 直近で全cronサーバーの入れ替えのため、全バッチが何をやってどういう挙動をしているかを調査しないといけなかったので、苦労にものすごく共感した。。
  • 成否やスケジューリングの管理をしたくてRundeckとか試してたけど、前者だけならhorensoだけで良さそう。後者に関しては冗長化の観点をすっぽり抜けていて「確かに」と思った。
  • 前職もそうだったけど、過度にcronに依存するシステムはつらい。。
  • プレゼンが聞きやすかった。

Wazuhを利用した大統一サーバ監査基盤

https://speakerdeck.com/pyama86/wazuhwoli-yong-sita-da-tong-sabajian-cha-ji-pan

  • セキュリティインシデントの調査は本当にすごく手間だし時間もかかるよね。。
  • またToB要件で顧客情報を扱うケースに遭遇したら入れておきたい

エンジニアリング組織論への招待 -2つのDXと技術的負債-

https://speakerdeck.com/hirokidaichi/2tufalsedxtoji-shu-de-fu-zhai-yapc-tokyo-2019

  • ペアプロよりも要求インスペクションの方が品質改善効果が高いというのは良い知見。
  • 「交換可能であること」が良いことと言語化されていて、すっきりした。

Perlでも分散トレーシングしたい!AWS::XRay による解析とその実装

https://speakerdeck.com/fujiwara3/yapc-tokyo-2019

  • 新規でPerlでサービス作って、マイクロサービスを進めるときには使いたい。
  • 今担当しているサービスはモノリスで、ここからマイクロサービスに切り分けてくこともないので、Devel::KYTProfで十分そう。

Dive into MySQL Error

https://speakerdeck.com/yoku0825/dive-into-mysql-error-yapc-tokyobaziyon

  • 今まで適当にエラーのメッセージやコードを見ていたのが、実際MySQLソースコード上でどういう風に定義・実装されているのがかわかってよかった。
  • 出力メッセージは割と適当な場合もあるので、正直に捉えないほうがいいこともある。

Erlangことはじめ

Erlangことはじめ

erlangを書くことになったので、初歩的なスクリプトの実行からテストの実行までを勉強。

ディレクトリ構成

myproject
├── ebin
│   └── mymath.beam    # コンパイル時に生成される
├── rebar
├── src
│   └── mymath.erl
└── test
    └── mymath_test.erl

rebarを持ってくる

Erlangのビルドツールであるrebarを持ってくる。コンパイルやテスト実行が簡単になる。

git clone https://github.com/rebar/rebar.git
cd rebar
make

コードを書く

フィボナッチ数、階乗を返す関数を定義したモジュールを書く。

src/mymath.erl

-module(mymath).
-export([fib/1, fact/1]).

fib(X) when X < 0 ->
    throw({error, 'Negative Number'});
fib(0) ->
    1;
fib(1) ->
    1;
fib(X) ->
    fib(X - 1) + fib(X - 2).

fact(X) when X < 0 ->
    throw({error, 'Negative Number'});
fact(0) ->
    0;
fact(1) ->
    1;
fact(X) ->
    X * fact(X - 1).

試しに実行してみる。

$ rebar compile
$ erl -pa ebin
Erlang/OTP 17 [erts-6.4] [source] [64-bit] [async-threads:10] [hipe] [kernel-poll:false]

Eshell V6.4  (abort with ^G)
1> mymath:fib(3).
3
2> mymath:fact(3).
6

テストを書く

mymath.erlに対する単体テストを書く。 テストのファイルはmymath_test.erlになる。 テストでは*_testのメソッドが呼ばれる。

test/mymath_test.erl

-module(mymath_test).
-include_lib("eunit/include/eunit.hrl").

fib_test() ->
    ?assertThrow({error, 'Negative Number'}, mymath:fib(-1)),
    ?assertEqual(1, mymath:fib(0)),
    ?assertEqual(1, mymath:fib(1)),
    ?assertEqual(2, mymath:fib(2)),
    ?assertEqual(3, mymath:fib(3)),
    ?assertEqual(5, mymath:fib(4)).

fact_test() ->
    ?assertThrow({error, 'Negative Number'}, mymath:fact(-1)),
    ?assertEqual(0, mymath:fact(0)),
    ?assertEqual(1, mymath:fact(1)),
    ?assertEqual(2, mymath:fact(2)),
    ?assertEqual(6, mymath:fact(3)),
    ?assertEqual(24, mymath:fact(4)).

Eunit には他にもテスト用のメソッドがたくさんある。

テストを実行する

テストを実行してテストが通るか確かめる。

$ ./rebar eunit
==> myproject (eunit)
  All 3 tests passed.

参考

リーダブルコードを読んだ

今更ながら「リーダブルコード」を読んだ。

きっかけ

職場では開発したコードをチーム内のメンバーでお互いに見せ合い、内容に問題ないかをレビューしている。 あるとき別のメンバー同士でレビューをしてる際に、「リーダブルコードに書かれているから…」という指摘でもめた。 「リーダブルコードに書かれているから」だけではそもそもどういう意味を持っているのかがわからない。 リーダブルの定義は各々によって違うはず。 そのため指摘理由にはならないと思った。 しかし、そもそも何が書かれてるのか知らないのはよくないと思い読んでみた。

自分の立場

そもそも自分の立場はチーム内に限らず社内で書かれているコード全般のレビューを担当している。

所感

コードに美しさを求め、読みやすさやメンテナンス性を向上させようという意図は概ね同意。 ただ、内容というよりは、これを読んだチームメンバーの解釈には疑問を感じた。

コメント

コードだけでは意図が伝わりにくい箇所の要約や実装理由の説明目的に使うのは良いが、とにかく何でも説明すれば良いという勢いでダラダラ書くのはよくない。 コメントがあれば多少複雑でも構わないだろう、という免罪符にはなり得ない。 13章「短いコードを書く」という意識がごっそり抜け落ちているケースが多い。 「どうコメントを書くか」よりも「どうすればコメントを書かなくて済むか」をより意識すべきだと思う。

レビューにどう適用していくか

書く側がコードの美しさに意識を置くのは良いと思う。 しかし、レビューする側がこれを強制して、これを満たしていないコードを修正させるのはそれほど良いこととは思わない。 チーム内で開発するコードに美しさを求めるのであれば、レビューされる側・する側でこの本を読むかコーディングガイドラインを作って明文化するなどして共通認識を持つ必要がある。 そうでなければ、「リーダブルじゃない」という指摘ではどう修正すれば分からないし、個人に依存する部分が強いので、感情的な対立を生んで不毛な争いになるおそれがある。

自分としては、インデントがバラバラなどよほどひどい場合であれば別だが、カンマや演算子の位置が揃ってない程度であれば、レビューで指摘はしない。 これを指摘し始めると結構キリがなくなるケースがあり、そちらに気をとられて、本来もっと大切な無駄なロジックやバグを見落として指摘が漏れてしまうから。 また、コードの整形に関して指摘する場合は、例えば「カンマの位置を揃えてください」と単に指摘するよりも、「perltidyのような整形ツールにかけてください」という指摘をするようにしている。 これは、手動でカンマの位置を揃える手間に面倒さを感じて対応してもらえないケースをできるだけ回避するのと、ツールを使ってくださいということで指摘にある程度の客観性が生まれて受け入れてもらいやすいと考えているからである。

Slow Query 対策 - コンテンツの前後リンク

Slow Query 対策 - コンテンツの前後リンク

背景

例えば日記などのコンテンツのように、ユーザーとコンテンツが1対Nで存在するケースで、任意のコンテンツで前後のコンテンツのリンクを出したい。 そのためには、あるコンテンツのIDの近傍データにアクセスしてタイトルなどを取ってくる必要がある。

想定するテーブルは下記の通り。

CREATE TABLE `diary` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` int(10) unsigned NOT NULL,
  `title` varchar(100) NOT NULL,
  `body` text NOT NULL,
  `post_time` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_1` (`user_id`,`post_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

idx_1は任意のユーザーの日記を更新順で取得するためのものである。ユーザーの日記一覧で必要になるクエリで利用する。

user_idのばらつきにより既存クエリでは使われるインデックスが変わってくるので、今回のケースではユーザー数が1000、1ユーザーあたり約1000件の日記を保持するケースで考える。

既存クエリ

次の日記を取得する場合では、閲覧中の日記IDよりも多いものをWHERE句で限定して、IDでソートして最小のものを1件取得していた。

SELECT id, user_id, title FROM diary WHERE user_id = 1 AND id > 498740 ORDER BY id ASC LIMIT 1;

EXPLAIN結果は次の通り。

mysql> EXPLAIN SELECT id, user_id, title FROM diary WHERE user_id = 1 AND id > 498740 ORDER BY id ASC LIMIT 1;
+----+-------------+-------+------+---------------+-------+---------+-------+------+-----------------------------+
| id | select_type | table | type | possible_keys | key   | key_len | ref   | rows | Extra                       |
+----+-------------+-------+------+---------------+-------+---------+-------+------+-----------------------------+
|  1 | SIMPLE      | diary | ref  | idx_1         | idx_1 | 4       | const | 1005 | Using where; Using filesort |
+----+-------------+-------+------+---------------+-------+---------+-------+------+-----------------------------+

filesortが発生しており、これがソート対象の件数によってはSlow Queryになることがある。

改善案

サブクエリを利用して、指定したIDより大きくて最小のものを求め、それをWHERE句で指定するようにする。

SELECT id, user_id, title FROM diary WHERE id = 
(SELECT MIN(id) FROM diary WHERE user_id = 1 AND id > 498740);

EXPLAIN結果は次の通り。

mysql> EXPLAIN SELECT id, user_id, title FROM diary WHERE id = 
    -> (SELECT MIN(id) FROM diary WHERE user_id = 1 AND id > 498740);
+----+-------------+-------+-------+---------------+---------+---------+-------+------+--------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref   | rows | Extra                    |
+----+-------------+-------+-------+---------------+---------+---------+-------+------+--------------------------+
|  1 | PRIMARY     | diary | const | PRIMARY       | PRIMARY | 4       | const |    1 |                          |
|  2 | SUBQUERY    | diary | ref   | PRIMARY,idx_1 | idx_1   | 4       |       | 1005 | Using where; Using index |
+----+-------------+-------+-------+---------------+---------+---------+-------+------+--------------------------+

filesortが消えた

ベンチマーク

下記コードで上記2パターンについてベンチマークを行い比較した。 一番古い日記から順番に次の日記へ辿っていくケースである。

use strict;
use warnings;

use DBI;
use Benchmark qw(:all);

my $dbh = DBI->connect('dbi:mysql:sota', 'root', '');

my $sth1 = $dbh->prepare(q{
    SELECT id, user_id, title FROM diary FORCE INDEX(idx_1) WHERE user_id = ? AND id > ? ORDER BY id ASC LIMIT 1
});

my $sth2 = $dbh->prepare(q{
    SELECT id, user_id, title FROM diary WHERE id = 
    (SELECT MIN(id) FROM diary WHERE user_id = ? AND id > ?)
});

my $user_id = 1;
my $start_id = do {
    my ($row) = $dbh->selectrow_array('SELECT MIN(id) FROM diary WHERE user_id = ?', undef, $user_id);
    +$row;
};

cmpthese(10, {
    before => sub {
        my $id = $start_id;

        while ( $id ) {
            $sth1->execute($user_id, $id);
            my $row = $sth1->fetch;
            $id = $row ? $row->[0] : undef;
        }
    },
    after => sub {
        my $id = $start_id;

        while ( $id ) {
            $sth2->execute($user_id, $id);
            my $row = $sth2->fetch;
            $id = $row ? $row->[0] : undef;
        }
    },
});

結果を以下に示す。

         Rate before  after
before 17.2/s     --   -17%
after  20.8/s    21%     --

改善案の方が高速になった。

余談1

実は、(user_id, id)のインデックスを張ればどちらも高速化される。 特に既存クエリはfilesortが発生しなくなるので、修正の必要がなくなる。

         Rate  after before
after  35.7/s     --    -4%
before 37.2/s     4%     --

ただ、今回遭遇した現場では、既に100近くのDBにshardingされており、総レコード数も数千万のオーダーのため、ALTER文が実行しづらい状況にあった。

余談2

前後リンクはSEO対策の一環で、検索クローラー回遊性向上のために必要になった。 しかし、普段ユーザーがアクセスしないデータにまでアクセスが発生するようになったため、I/Oがかなり増えたのが痛い。 リンクに日記のタイトルを出さなくてもよいのであれば、色々ごまかし方はあるのだが…。