マクロを使ってPostgreSQLの接続情報を取得する。


Elixir Advent Calendar 2014 21日目。
PostgreSQLに接続する際に使用する`psql`コマンドが利用している、`.pgpass`と`.pg_service.conf`をElixirで読み込んで接続情報を取得する方法を書きたいと思います。

このような事をやりたかった理由は、Elixirのプロジェクト内に接続情報を保持したくなかったのと、psqlと同じ情報を利用したかったためです。

# やりたい事

* `.pg_service.conf`に記述されているサービス名を引数に渡すと、`.pgpass`/`.pg_service.conf`から接続情報を取り出す。

# 準備

$HOMEに`.pgpass`と`.pg_service.conf`を作成しときます。
設定内容は以下の通り。

```text:.pgpass
# hostname:port:database:username:password
*:*:admindb:*:passwd_admin
*:*:userdb:*:passwd_user
```

```ini:.pg_service.conf
[srv_user]
host=awssrv.example.com
port=5432
dbname=userdb
user=admin

[srv_admin]
host=awssrv.example.com
port=5432
dbname=admindb
user=admin
```

# 前提条件

* 接続情報に対してデフォルト値を設けていないので、`.pg_service.conf`には`password`以外のすべての項目を設定する。
* マクロを使うのは`.pgpass`の情報を取得する部分のみ。
* `.pgpass`のpasswordには":"は入っていないこと。(エスケープに対応してないです。)

# 使用するライブラリ

`.pg_service.conf`はini形式なので、読み込みに[econfig](https://github.com/benoitc/econfig)を使用します。

# コードを書いてみる

## .pg_service.conf読み込み部分

`.pg_service.conf`に設定情報が無い場合、`Application.get_env`で`mix.exs`から取得してます。

```elixir:lib/pg/service.ex
defmodule Pg.Service do
  def init do
    :econfig.register_config(:pg_service, [config_file], [])
    :econfig.subscribe(:pg_service)
  end

  def connection_data(dbtype) do
    case :econfig.get_value(:pg_service, dbtype) do
      [] ->
        Application.get_env(:define_on_compile_phase, List.to_atom dbtype)
      _ ->
        [
          hostname: :econfig.get_value(:pg_service, dbtype, 'host'),
          username: :econfig.get_value(:pg_service, dbtype, 'user'),
          password: :econfig.get_value(:pg_service, dbtype, 'password'),
          database: :econfig.get_value(:pg_service, dbtype, 'dbname'),
          port:     List.to_integer(:econfig.get_value(:pg_service, dbtype, 'port')),
        ]
    end
  end

  defp config_file do
    String.to_char_list(System.user_home) ++ '/.pg_service.conf'
  end
end
```

## .pgpassのパスワード取得のパターンマッチングに使用するStruct

```elixir:lib/pg/pass_struct.ex
defmodule Pg.PassStruct do
  defstruct [hostname: nil, port: nil, database: nil, username: nil, password: nil]
end
```

## .pgpass読み込み部分

このファイルの中には、`Pg.ReadPgpass`と`Pg.Password`の二つのmoduleが含まれています。
ファイルを分けなかった理由は、コンパイル時に`Pg.Password`の内部でマクロが実行され`Pg.ReadPgpass`に依存している為です。
(ファイルを分割したら、コンパイルエラーになります。)

マクロを使用しているのは、`Pg.Password` moduleで、
Pg.PassStructを使ってパターンマッチング出来る`get`関数を定義して、マッチするとパスワードを返す様になってます。


```elixir:lib/pg/read_pgpass.ex
defmodule Pg.ReadPgpass do
  def get do
    {:ok, bin} = File.read(config_file)
    bin |> String.split("\n") |> resolution
  end

  defp config_file do
    String.to_char_list(System.user_home) ++ '/.pgpass'
  end

  defp resolution(config_arr), do: resolution(config_arr, [])
  defp resolution([], acc), do: acc
  defp resolution([""|t], acc), do: resolution(t, acc)
  defp resolution([<<"#", _rest::binary>>|t], acc), do: resolution(t, acc)
  defp resolution([h|t], acc) do
    ltuple = String.split(h, ":") |> List.to_tuple
    {_, r} = {ltuple, %Pg.PassStruct{}} |>
      set_hostname |>
      set_port |>
      set_database |>
      set_username |>
      set_password
    resolution(t, [r|acc])
  end

  defp set_hostname({t, r}) do
    case (t |> elem 0) do
      "*" -> {t, r}
      v -> {t, Map.put(r, :hostname, v |> String.to_char_list)}
    end
  end
  defp set_port({t, r}) do
    case (t |> elem 1) do
      "*" -> {t, r}
      v -> {t, Map.put(r, :port, v |> String.to_integer)}
    end
  end
  defp set_database({t, r}) do
    case (t |> elem 2) do
      "*" -> {t, r}
      v -> {t, Map.put(r, :database, v |> String.to_char_list)}
    end
  end
  defp set_username({t, r}) do
    case (t |> elem 3) do
      "*" -> {t, r}
      v -> {t, Map.put(r, :username, v |> String.to_char_list)}
    end
  end
  defp set_password({t, r}) do
    case (t |> elem 4) do
      "*" -> {t, r}
      v -> {t, Map.put(r, :password, v |> String.to_char_list)}
    end
  end
end

defmodule Pg.Password do
  list = Pg.ReadPgpass.get

  def list_to_struct(list) do
    %Pg.PassStruct{
      hostname: list[:hostname],
      port:     list[:port],
      database: list[:database],
      username: list[:username],
    }
  end

  Enum.each list, fn(pgpass) ->
    opt = Keyword.new([hostname: pgpass.hostname, port: pgpass.port, database: pgpass.database, username: pgpass.username])
    opt = Enum.reject opt, fn(y) ->
      case y do
        {_, nil} -> true
        _ -> false
      end
    end
    password = pgpass.password

    def get(%Pg.PassStruct{unquote_splicing(opt)}=_val) do
      unquote(password)
    end
  end
end
```

# 動作確認

```iex:iex
% iex -S mix
Erlang/OTP 17 [erts-6.3] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Interactive Elixir (1.1.0-dev) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Pg.Service.init
true
iex(2)> connection_info = Pg.Service.connection_data("srv_admin")
[hostname: 'awssrv.example.com', username: 'admin', password: :undefined,
 database: 'admindb', port: 5432]
iex(3)> password = connection_info |> Pg.Password.list_to_struct |> Pg.Password.get
'passwd_admin'
iex(4)> Keyword.put connection_info, :password, password
[password: 'passwd_admin', hostname: 'awssrv.example.com', username: 'admin',
 database: 'admindb', port: 5432]
iex(5)>
```

# まとめ

* 気をつけないといけないのは、`.pgpass`を更新してもコンパイルしたモジュールは古いパスワードを返します。
更新した場合は、`mix do clean, compile`を行う必要があります。
* Stage環境/本番環境できっちり`.pgpass`と`.pg_service.conf`を使い分けている場合、本番環境でコンパイルしないといけません。Stage環境で作成したモジュールを本番に持って行っても、`.pgpass`の情報はStage環境のママです。

あと同じ考え方で、AWSのキー/証明書等をを取り込ませたりする事も出来ると思います。
もっと最適化したいな。

明日は @keithseahus さんです。