YAMLローダーをつくってみた!

こんにちわ。平井です。
夜は寒くなってきましたね〜。
温かくしてお過ごしください。

はじめに

アプリをつくるとき必ず作るものに設定ファイルがあります。
開発環境、検証環境、本番環境それぞれに応じた設定ファイルをつくりますよね。
設定ファイルの元祖のデータ形式(ファイル形式)といえば、ini (主に C++ など)や properties (主に Java など)です。
こんなやつです。

[aws]
region=ap-northeast-1

[api]
xxx-api=https://xxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev
yyy-api=https://yyyyyyyyyy.execute-api.ap-northeast-1.amazonaws.com/dev
zzz-api=https://yyyyyyyyyy.execute-api.ap-northeast-1.amazonaws.com/dev


Javaであれば、以下のように java.util.ResourceBundle や java.util.Properties を使って、properties ファイルを読み込めます。

java.util.ResourceBundleの場合

import java.util.ResourceBundle;
 
public class Main {
 
    public static void main(String[] args) {
        ResourceBundle rb = ResourceBundle.getBundle("config.properties");
        
        System.out.println(rb.getString("region"));
        System.out.println(rb.getString("xxx-api"));
    }
 
}

java.util.Propertiesの場合

import java.io.FileInputStream;
import java.io.InputStream;
import java.io.IOException;
import java.util.Properties;
 
public class Main {
 
    public static void main(String[] args) {
        Properties properties = new Properties();
                
        try {
            InputStream is = new FileInputStream("config.properties");
            properties.load(is);
            System.out.println(properties.getProperty("region"));
            System.out.println(properties.getProperty("xxx-api"));
        } catch (IOException e) {
            e.printStackTrace();
        }
        
    }

ini や properties の利点はクラスを作らずにキーを指定することで設定ファイルの値を参照可能なところです。

一方で、最近の設定ファイルのデータ形式には、たとえば Docker Compose などでも使われている YAML があります。
こんなやつです。

version: "3.8"
services:
  web:
    build: .
    ports:
      - "5000:5000"
    volumes:
      - .:/code
      - logvolume01:/var/log
    links:
      - redis
  redis:
    image: redis
volumes:
  logvolume01: {}


YAML の利点はこんな感じです。

  • JSON と違ってコメントが書ける
  • インデントで階層表現するので冗長な記述をしなくて済む

一方で、YAML はどんなプログラミング言語のライブラリでもだいたいそうなのですが、YAML ファイルをマッピングするもの、たとえば Java であればクラス、Go であれば構造体を書かないといけません。
この場合、設定ファイルに修正が発生すると以下の二箇所を修正しないとなりません。

  • YAML ファイルをマッピングするクラスや構造体
  • そのクラスや構造体を使用してる部分

ini ファイルや properties ファイルであれば、それをマッピングするものを修正する必要がありません。
そこで、今回は「YAML を使いたい、しかも ini や properties みたいにキーを指定するだけ値を取得したい」という願いを叶える YAML ローダーをつくってみました。

仕様

https://yaml.org/ によれば、YAML の最新バージョンは1.2ですが、これのすべての構文規則に対応した YAML ローダーをつくるのは大変です。
アプリをつくるのに必要な最低限な機能があればいいです。
そこで、今回はミニマム、かつ実装しやすいオレオレ仕様として、以下のようにしました。

  • #から始まる文字列はコメント
  • -はひとつの階層構造を意味する
  • :で終わる文字列はキーを構成する要素
  • 記号のない文字列は値

今回は以下の YAML による設定ファイルを読み込むこととします。

#開発環境用のダミー設定
- mysql:
	ip: localhost
	port: 3306
	db_name: bigbang
	user: bigbang
	password: bigbang
- hoge:
	piyo: fuga

実装と解説

Java で実装すると、ソースコードはこんな感じになります(パッケージの記述は省略しています)。

import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;

import lombok.val;
import lombok.extern.java.Log;

@Log
public class ConfigManager {
	private Map<String, String> config;

	public ConfigManager() {
		val profile = System.getenv("PROFILE");
		val path = "/config-" + profile + ".yml";
		val scanner = new Scanner(ConfigManager.class.getResourceAsStream(path));
		scanner.useDelimiter(" |\\n|\\r|\\r\\n|\\t");
		config = new HashMap<String, String>();
		val tokens = new ArrayDeque<String>();
		while (scanner.hasNext()) {
			val token = scanner.next();
			if (token.isEmpty()) continue;
			if (token.startsWith("#")) continue;
			else if (token.equals("-")) tokens.clear();
			else if (token.endsWith(":")) tokens.add(token);
			else {
				val key = new StringBuilder();
				tokens.stream().forEach(e -> key.append(e));
				config.putIfAbsent(key.toString(), token);
				tokens.removeLast();
			}
		}
	}

	
	/**
	 * 設定値を取得するメソッド
	 * 
	 * @param key 設定ファイルのキー
	 * @return キーに対する値
	 */
	public String getValue(final String key) {
		val value = config.get(key);
		if (value == null) {
			log.severe("設定ファイルでキーに対する値が存在しません,"+key);
			System.exit(1);
		}
		return config.get(key);
	}
}

ポイントはこんなところです。

  • 環境変数 PROFILE から読み込む設定ファイル名を知る
  • 空白スペース、改行、復帰、復帰+改行、水平タブといった制御文字を区切り文字にする
  • ArrayDeque の tokens をスタックのように使う
  • 読み込む文字列が値の文字列だったら、tokens の要素を格納した順序で全部つなげてキーとする(ちなみに tokens の末尾に格納されている文字列を取り出して、それといま読み込んでる:で終わる文字列をつなげて tokens に格納すると、値の文字列に遭遇するごとに token を全部つなげなくていいのでより効率的ですね)

そして、テストコードはこんな感じです。

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class ConfigManagerTest {
	@Test
	void testGetValue() {
		ConfigManager cm = new ConfigManager();
		Assertions.assertEquals("localhost", cm.getValue("mysql:ip:"));
		Assertions.assertEquals("3306", cm.getValue("mysql:port:"));
		Assertions.assertEquals("bigbang", cm.getValue("mysql:db_name:"));
		Assertions.assertEquals("bigbang", cm.getValue("mysql:user:"));
		Assertions.assertEquals("bigbang", cm.getValue("mysql:password:"));
		Assertions.assertEquals("fuga", cm.getValue("hoge:piyo:"));
	}
}

キーは文字列の末尾に:のついたトークンをつなげた形になります。

ちなみにですが、Go で書くとソースコードはこんな感じになります(パッケージの記述は省略しています)。

import (
  "os"
  "strings"
  "io/ioutil"
)

func GetConfig() map[string]string {
  profile := os.Getenv("PROFILE")
  file_path := "./config/" + profile + ".yml"
  buf, err := ioutil.ReadFile(file_path)
  if err != nil {
    panic(err)
  }
  config := map[string]string{}
  tokens := []string{}
  for _, token := range strings.Fields(string(buf)) {
    if string(token[0]) == "#" {
      continue
    } else if token == "-" {
      tokens = nil
    } else if string(token[len(token)-1]) == ":" {
      tokens = append(tokens, token)
    } else {
      var key string
      for _, e := range tokens {
        key += e
      }
      config[key] = token
      tokens = tokens[:len(tokens)-1]
    }
  }
  return config
}

区切り文字を考えなくていい以外はだいたい同じです。

おわりに

自分好みなライブラリがないときは自分でつくってみるのもいいものです!
それではまた。