2021年5月16日日曜日

Sequelizeでレプリケーション設定をURI指定でやりたい

タイトルまんまです。

Sequelizeでは接続オプションとしてURI形式の文字列を指定できるんですが、この方法だとレプリケーションを使えません。

これをなんとかして使えるようにしたいという話です。

なんで必要?

そらもうアレよ。

.envファイルとかを使って接続情報を環境変数から渡したいときあるじゃないですか。

JSON文字列を渡してプログラム側でパースするのもだるいし、そもそもJSON文字列を作るのが面倒くさい。

というわけで、極力シンプルにURI形式でレプリケーションを使いたいんです。

本当に使えないの?

一応調べてみました。まずは一次ソースとして公式ドキュメントを見てみましょう。

Connecting to a database

ここを見ると、sqlite::memory:とかpostgres://user:pass@example.com:5432/dbnameといったURI形式でデータベースに接続接続できると書いてあります。このページではレプリケーションについては特に言及していません。

次にレプリケーション設定のページを見ると、replicationプロパティーにreadwriteを指定することでレプリケーションできると書いてありますが、ここではURIでの指定については特に言及していません。というわけで多分対応していない。

ソースコードを見てみる

実際には対応しているけどドキュメントの更新を忘れている・・・という可能性もゼロではないので、念のためソースコードを確認。

URIの処理部分はここですが、hostportを設定しているけどreplicationは特に設定していません。というわけでURIを渡すだけでレプリケーションの設定はできません。

次に、replicationプロパティーのread, writeにURI形式の文字列を渡せないかと調べてみました。

レプリケーション設定を実際に使っているのはここですが、config.replication.writelodash.defaults()を適用しています。つまりconfig.replication.writeはオブジェクトが前提であり、ここにURIを渡すことはできないということ。

というわけで、やっぱりムリくさい。

解決方法

文字列からレプリケーションに対応した初期化オブジェクトを作る関数を作ることにしました。

こんな仕様にしてみます。

  • マスター・リードレプリカはスペースで区切る
  • スペースで区切られた最初の要素をマスター、2番目以降の要素がリードレプリカとする
  • スペースがない場合、つまり要素が1つしかない場合は、レプリケーションを使わない(通常のURI指定との互換性)
  • リードレプリカのURIはプロトコルを含めない(マスターとそれ以外で異なるDBを指定できないようにする)

例えばpostgres://user:pass@example.com:5432/dbnameだとレプリケーションを使わず、postgres://user:pass@example.com:5432/dbname //user1:pass1@example1.com:5432/dbname1 //user2:pass2@example2.com:5432/dbname2だとexample.comをマスター、example1.com / example2.comをリードレプリカとして初期化します。

ソースコードはこんなかんじ。TypeScriptで書いています。

import {ConnectionOptions, Dialect, Options} from "sequelize";

/**
 * 接続URIを解析して初期化設定を構築
 * @param separatedUri 接続URI(スペース区切りでレプリケーション可)
 * @param defaultOptions 既定オプション
 * @returns 初期化設定
 */
export function buildConfig(separatedUri: string, defaultOptions: Options = {}): Options {
	const [writeUri, ...readUris] = separatedUri.split(/\s+/);

	return {
		...defaultOptions,
		...buildConfigCore(writeUri, readUris),
	};
}

/**
 * 初期化設定構築処理のコア部分
 * @param writeUri 書き込み用URI
 * @param readUris 読み込み用URI
 * @returns 初期化設定
 */
function buildConfigCore(writeUri: string, readUris: string[]): Options {
	if(readUris.length === 0) {
		// readが指定されていなければレプリケーションを使わない
		return buildConfigWithoutReplication(writeUri);
	} else {
		// レプリケーションあり
		return buildConfigWithReplication(writeUri, readUris);
	}
}

/**
 * レプリケーションなしの初期化設定を構築
 * @param writeUri 接続URI
 * @returns 初期化設定
 */
function buildConfigWithoutReplication(writeUri: string): Options {
	const parsedWriteUri = new URL(writeUri);
	const dialect = getDialect(parsedWriteUri);

	if(dialect === "sqlite") {
		// SQLiteは専用処理
		return {
			dialect: dialect,
			storage: parsedWriteUri.pathname,
		};
	} else {
		// SQLite以外
		const connectionOptions = buildConnectionOptions(parsedWriteUri);
		return {
			dialect: dialect,
			host: connectionOptions.host,
			port: Number(connectionOptions.port),
			username: connectionOptions.username,
			password: connectionOptions.password,
			database: connectionOptions.database,
		};
	}
}

/**
 * レプリケーションありの初期化設定を構築
 * @param writeUri 書き込み用URI
 * @param readUris 読み込み用URI
 * @returns 初期化設定
 */
function buildConfigWithReplication(writeUri: string, readUris: string[]): Options {
	const parsedWriteUri = new URL(writeUri);
	const dialect = getDialect(parsedWriteUri);

	// レプリケーション設定
	return {
		dialect: dialect,
		replication: {
			write: buildConnectionOptions(parsedWriteUri),
			read: readUris.map((readUri) => {
				// 先頭にdialectを追加
				const parsedReadUri = new URL(`${dialect}:${readUri}`);
				return buildConnectionOptions(parsedReadUri);
			}),
		},
	};
}

/**
 * URLインスタンスから接続オプションを構築
 * @param url 接続URI
 * @returns 接続オプション
 */
function buildConnectionOptions(url: URL): ConnectionOptions {
	return {
		host: url.hostname,
		port: url.port,
		username: decodeURIComponent(url.username),
		password: decodeURIComponent(url.password),
		database: getDatabase(url),
	};
}

/**
 * DB種類を取得
 * @param url URL
 * @returns DB種類
 */
function getDialect(url: URL): Dialect {
	const protocol = url.protocol;
	if(!protocol.endsWith(":")) {
		return protocol as Dialect;
	}

	// 末尾の ":" を除去
	return protocol.substr(0, protocol.length - 1) as Dialect;
}

/**
 * データベース名を取得
 * @param url URL
 * @returns データベース名
 */
function getDatabase(url: URL): string {
	const pathname = url.pathname;
	if(!pathname.startsWith("/")) {
		return pathname;
	}

	// 先頭の "/" を除去
	return pathname.substring(1);
}

これをimportして、buildConfig("postgres://user:pass@example.com:5432/dbname //user1:pass1@example1.com:5432/dbname1 //user2:pass2@example2.com:5432/dbname2", {pool: {max: 10}});のように呼び出すと初期化設定オブジェクトをいい感じに作ってくれます。これをそのままSequelizeコンストラクターに渡してやれば、通常の接続もレプリケーションも環境変数から簡単に設定できるようになります。やったね!

注意事項

URIのパース部分は、Sequelizeのパース仕様に厳密に従っているわけではありません。たとえばSequelizeではクエリーストリングでhostを上書きできますが、個人的に必要ではないので上記のコードではそこまで対応していません。

そのうち公式にissueとかpull requestでも送ってやろうかしらん。issueをreplication uriで検索しても何も出てこないけど、需要ないのかな。

0 件のコメント:

コメントを投稿