GAS

Stencilを使って、スプレッドシートのログの定義書から、ログ送信コードを自動生成する

swift-stencil-template-generate

Stencilを使って、スプレッドシートのログの定義書から、ログ送信コードを自動生成する

普段開発をしていると、ログ定義書のようにスプレッドシートにまとめられた情報を元に、コードを書いて、ログを送るといった実装をすることがあると思います。
しかしスプレッドシートから手動でコードに書き起こすと、コピペミスが発生することもあり、何より面倒臭いです!
そこでスプレッドシートのデータを元にswiftのコードを自動生成し、手抜き煩わしい作業から解放されたいと思い、色々試行錯誤してみました!

手順

今回の登場人物はこちらです!

  1. Google Apps Script(通称 GAS)
  2. Stencil
  3. StencilSwiftKit

Google Apps Scriptsはスプレッドシートなどで利用できるプラットフォーム、JavaScript感覚で記述することができます。Excelでいうとこのマクロ。
Stencilは、Swift向けのテンプレート生成ツール。SwiftGenなどでも使われている。
StencilSwiftKitは、上記のStencilの拡張機能のようなツールです。SwiftGenのチームによってメンテされています。 実装の流れとしては、以下の通りです。

  1. Google Apps Scriptsを使って、スプレッドシートの情報をJSONに変換する
  2. 上記のJSONを読みこみ、Stencilを使って、swiftファイルを生成するSwift Package Manager製のコマンドラインツールを作成
  3. 上記のswiftファイルを使用したいアプリで上記のSPMのツールを使って、コードを生成・利用する。

1. Google Apps Scriptsを使って、スプレッドシートの情報をJSONに変換する

まずはサンプルとしてこちらのシートをJSONに変換します。 その前の準備段階として、jsonで各カラムのkeyとなる値(idやevent_name)などを3行目に追加しています。
そして実際に上記のスプレッドシートをJSONを生成するための処理は、こちらに記載があります。

大きく記載している処理は以下の通りです。
①スプレッドシートのデータをJSONに変換する処理
②スプレッドシートのメニューにJSONを出力用のボタンを追加する処理
③JSONダウンロード用のボタンを表示するHTMLページを表示する処理

①の手順については、スプレッドシートの書き方やどのようなJSONを出力したいかによって変わってきますが、今回の処理を行うことで、以下のようなJSONが出力されます。

{
	"eventData": [
		{
			"id": 1,
			"category": "Authentication",
			"event_name": "sign_in_success",
			"transmission_timing": "When successfully signed in",
			"remarks": "",
			"variables": [
				{
					"key": "user_id",
					"shouldShow": false
				},
				{
					"key": "error_msg",
					"shouldShow": false
				},
				{
					"key": "keyword",
					"shouldShow": false
				},
				{
					"key": "index",
					"shouldShow": false
				},
				{
					"key": "offset",
					"shouldShow": false
				}
			]
		}
	]
}

GASのコード自体は、メニューの「拡張機能」> 「Apps Scripts」をクリックして開くエディターから追加することができます。
実行すると、スプレッドシートのメニューに「JSON」ボタンが表示され、クリックして進むと、処理後にJSONのダウンロードボタンが表示されます。

2. 上記のJSONを読みこみ、Stencilを使って、swiftファイルを生成するSwift Package Manager製のコマンドラインツールを作成

次に1.で生成したJSONをswiftのコードに変換するコマンドラインツールを作ります。

SPMを使ったコマンドラインツール自体の作り方は、こちらを参考にしてください。
今回重要なのは、Stencilというツールです。
.stencilというテンプレートファイルを作成して、そこにJSONのデータを渡すと、文字列が返却されるので、それを該当ファイルに書き込むことでこのフェーズは実現できます。
ではポイントになる点を確認します。コード全体はこちらで確認できます。
また今回使用しているライブラリの詳細は、こちらで確認できます。

まずは environmentを準備します。
注意点としては、作成する時に Environment オブジェクトを使用すると、 StencilSwiftKitの機能が使用できないので、気をつけましょう。

let filemanager = FileManager.default
let currentPath = filemanager.currentDirectoryPath
let templateFileFullPath = Path(stringLiteral: currentPath + "/" + ymlEntity.templateFilePathInfo.templateFileDirectory)
var environment = stencilSwiftEnvironment()
// 'templateFileFullPath' sets the directory where the template file is located
environment.loader = FileSystemLoader(paths: [(templateFileFullPath)])
// ①
{% macro logParameterStructParameter logDictionary %} public struct Log{% filter snakeToCamelCase %}{{ logDictionary.eventName }}{% endfilter %}Parameter: Codable { {% for variableDictionary in logDictionary.variables where variableDictionary.shouldShow %} public let {% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %}: String {% endfor %} public init( {% for variableDictionary in logDictionary.variables where variableDictionary.shouldShow %}
// ② {% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %}: String{% if not forloop.last %},{% endif %} {% endfor %} ) { {% for variableDictionary in logDictionary.variables where variableDictionary.shouldShow %} self.{% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %} = {% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %} {% endfor %} } }
{% endmacro %} // ② {% for logDictionary in logDictionaries where not logDictionary.isVariablesEmpty %} {% call logParameterStructParameter logDictionary %} {% endfor %}

変数の表示

変数の出力は、 {{ variable name }}と書くことで表示することができます。

filterの適用

上記のように変数を表示する時に、例えばcamelCaseの変数をsnake_caseで表示したい場合は、以下のように記述することで、snake_caseをcamelCaseに変更することができます。 |で分けることで、複数のフィルターを適用することができます。
variableDictionary.keyuser_idの場合. userId(snake_caseをcamelCaseにして、先頭の文字を小文字にする)と出力されます。

{% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %}

StencilSwiftKitには色々フィルターが用意されているので、気になる方はこちらを確認してください。

macro

{% macro {macro name} {parameter} %}{% endmacro %}で囲うことで、その間の処理をマクロとして、再利用することができるようになります(①)。
呼び出す時は、{% call {macro name} {parameter} %}と記述することで、該当のマクロを呼び出すことができます。

if文

{% if {Condition} %}{% endif %}で囲うことで、if文を実装することができます。
elseを使用することもできます(②)。

for文

②に記載している通り、配列のパラメーターを渡すことで、for文を使用することができます。
今回は、各ログの定義の情報をパラメーターとしてマクロを呼び出しています。
なおパラメーター用のstructを作成する必要があるかどうかを判定するisVariablesEmptyを使い、whereでフィルターをかけて、パラメータを生成するものだけマクロを実行するようにしています。
またこのfor文は他にも便利機能があって、今のループ文が何周目かを判定することができます。
今回の例では③にあるように、forloop.last
を使用することで、最後のループでない時は、, を追加する処理を追加しています。

Stencilの表現については、Stencilのドキュメントに色々記載があるので、調べるとだいたいやりたいことは見つかります。
またSwiftGenのstencilファイルも色々参考になると思います

3. 上記のswiftファイルを使用したいアプリで上記のSPMのツールを使って、コードを生成・利用する。

最後にコマンドラインツールを実行して、swiftファイルを生成します。
今回作成したツールは、ymlの設定ファイルを作成し、そのツール実行時のそのファイルのパスをパラメーターとして渡すような形にしています(デフォルトでCurrent directory)。

このツールをcloneして実行する場合は、設定ファイルをルートディレクトリに配置した後、 swift runを実行することで、指定したパスにswiftファイルが生成されます。
Mint経由でインストールした場合は、同じく設定ファイルをルートディレクトリに配置した後、mint run LogGen LogGenを実行することで、生成することができます。

+αでできそうなこと

  • JSONを生成する時に、バリデーションの処理を追加する
  • コマンドラインツールをMintなどでインストールできるようにする
  • Android用のファイルを生成する(参考)

まとめ

準備は大変だったけど、新しい技術に触れるのは楽しいし、無駄な作業から解放されて、とてもよかった!
今回使ったStencilはログ関連の処理以外にも使える可能性がありそうですし、エンジニアとしては、新しい技術に触れつつ、非Creativeな作業をできる限り減らすことが、ただ生産性を高めるだけでなく、モチベーションを高く維持しながら働くという観点でも重要だと感じました。

サンプルコード

参考

  • https://kuwk.jp/blog/spreadsheet2json/
  • https://buildmedia.readthedocs.org/media/pdf/stencil-template/latest/stencil-template.pdf

その後、temlateオブジェクトを生成して、JSONのデータを加工したものをrenderメソッドに渡します。

// set template file name
let template = try environment.loadTemplate(name: fileType.fileName)
// `parameterDictionary` is dictionary converted from JSON file.
let rendered = try template.render(parameterDictionary)
let generatedFilePath = outputDirectory + "/Generated.\(fileType.isiOS ? "swift" : "kt")"
// Write 'rendered' string to 'generatedFilePath' file.
try rendered.write(toFile: generatedFilePath, atomically: true, encoding: .utf8)

次にJSONをStencilのテンプレートに渡す準備です。
内容としては、JSONの文字列をCodableのモデルに変換してるだけですが、一部JSONにはない変数を既存の値から処理して定義しています(参考)。
理由としては、後述のテンプレートファイル内での処理に必要、もしくはこのモデル内で定義しておいた方がテンプレートファイル内の処理が簡潔に書くことができるからです。

スプレッドシート→最終的なswiftファイルまでの間にデータのフォーマットを加工するタイミングは①JSON生成時、②LogGenの処理、③テンプレートファイル内の3つあります。
それぞれ最終的な成果物を生成するために、どのステップで加工するかはそれぞれのスプレッドシートの書き方やそれぞれのステップの処理の書き方次第ですが、私はあまりGASが得意ではないので、できる限りswiftでの処理に寄せられればと思っていました。

ちなみに今回はそれぞれの①JSON生成時②LogGenの処理でデータ加工をしています。

最後に今回のメインのテンプレートファイルについて確認します。
今回作成したテンプレートファイルはこちらで、生成されたファイルはこちらで確認できます。
詳細については、JSONファイルとテンプレートファイル、生成されたファイルを比べるとわかりやすいと思います。

以下に今回作成したテンプレートファイル(ios_log.stencil)の一部を記載しました。
テンプレートを書く上でよく使う表現を記載します。
抜粋したコードは、ログ送信に使用するパラメーターのstructを記述するコードです。
なおテンプレートに記載している logDictionariesは以下のDictionaryをテンプレートに渡したもので、LogModelをDictionaryにしたものなので、LogModelの各変数をそのままテンプレートで利用できます。

let dictionary: [String: Any] = [
// An array of data in each row of the spreadsheet(LogModel) "logDictionaries": logDictionaries, ]
// ①
{% macro logParameterStructParameter logDictionary %} public struct Log{% filter snakeToCamelCase %}{{ logDictionary.eventName }}{% endfilter %}Parameter: Codable { {% for variableDictionary in logDictionary.variables where variableDictionary.shouldShow %} public let {% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %}: String {% endfor %} public init( {% for variableDictionary in logDictionary.variables where variableDictionary.shouldShow %}
// ② {% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %}: String{% if not forloop.last %},{% endif %} {% endfor %} ) { {% for variableDictionary in logDictionary.variables where variableDictionary.shouldShow %} self.{% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %} = {% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %} {% endfor %} } }
{% endmacro %} // ② {% for logDictionary in logDictionaries where not logDictionary.isVariablesEmpty %} {% call logParameterStructParameter logDictionary %} {% endfor %}

変数の表示

変数の出力は、 {{ variable name }}と書くことで表示することができます。

filterの適用

上記のように変数を表示する時に、例えばcamelCaseの変数をsnake_caseで表示したい場合は、以下のように記述することで、snake_caseをcamelCaseに変更することができます。 |で分けることで、複数のフィルターを適用することができます。
variableDictionary.keyuser_idの場合. userId(snake_caseをcamelCaseにして、先頭の文字を小文字にする)と出力されます。

{% filter snakeToCamelCase|lowerFirstLetter %}{{ variableDictionary.key }}{% endfilter %}

StencilSwiftKitには色々フィルターが用意されているので、気になる方はこちらを確認してください。

macro

{% macro {macro name} {parameter} %}{% endmacro %}で囲うことで、その間の処理をマクロとして、再利用することができるようになります(①)。
呼び出す時は、{% call {macro name} {parameter} %}と記述することで、該当のマクロを呼び出すことができます。

if文

{% if {Condition} %}{% endif %}で囲うことで、if文を実装することができます。
elseを使用することもできます(②)。

for文

②に記載している通り、配列のパラメーターを渡すことで、for文を使用することができます。
今回は、各ログの定義の情報をパラメーターとしてマクロを呼び出しています。
なおパラメーター用のstructを作成する必要があるかどうかを判定するisVariablesEmptyを使い、whereでフィルターをかけて、パラメータを生成するものだけマクロを実行するようにしています。
またこのfor文は他にも便利機能があって、今のループ文が何周目かを判定することができます。
今回の例では③にあるように、forloop.last
を使用することで、最後のループでない時は、, を追加する処理を追加しています。

Stencilの表現については、Stencilのドキュメントに色々記載があるので、調べるとだいたいやりたいことは見つかります。
またSwiftGenのstencilファイルも色々参考になると思います

3. 上記のswiftファイルを使用したいアプリで上記のSPMのツールを使って、コードを生成・利用する。

最後にコマンドラインツールを実行して、swiftファイルを生成します。
今回作成したツールは、ymlの設定ファイルを作成し、そのツール実行時のそのファイルのパスをパラメーターとして渡すような形にしています(デフォルトでCurrent directory)。

このツールをcloneして実行する場合は、設定ファイルをルートディレクトリに配置した後、 swift runを実行することで、指定したパスにswiftファイルが生成されます。
Mint経由でインストールした場合は、同じく設定ファイルをルートディレクトリに配置した後、mint run LogGen LogGenを実行することで、生成することができます。

+αでできそうなこと

  • JSONを生成する時に、バリデーションの処理を追加する
  • コマンドラインツールをMintなどでインストールできるようにする
  • Android用のファイルを生成する(参考)

まとめ

準備は大変だったけど、新しい技術に触れるのは楽しいし、無駄な作業から解放されて、とてもよかった!
今回使ったStencilはログ関連の処理以外にも使える可能性がありそうですし、エンジニアとしては、新しい技術に触れつつ、非Creativeな作業をできる限り減らすことが、ただ生産性を高めるだけでなく、モチベーションを高く維持しながら働くという観点でも重要だと感じました。

サンプルコード

参考

  • https://kuwk.jp/blog/spreadsheet2json/
  • https://buildmedia.readthedocs.org/media/pdf/stencil-template/latest/stencil-template.pdf
+1

COMMENT

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA