🍏

SourceryでDecodable準拠を雑に自動化する

2021/08/29に公開

モチベーション

  • SwiftでJSONデータをstruct, classにマッピングするとき Decodable を使うと楽
  • Int, Double, Stringなど、JSONとSwiftの型が一致するプロパティのみで構成されている場合は struct Xxx: Decodable のように記述するだけでok
  • しかし URLDate などSwift側にしか存在しない型にパースするためには独自のデコード処理を記述する必要がある
  • ボイラープレートなコードを繰り返し書かなければいけない、型の指定やOptionalの扱いなど実装ミスに気をつけなければいけない、という問題がある
  • その解決方法の1つとして自動化を図る

ゴール

以下のコードを入力とすると

import Foundation

/// sourcery: DecodableTarget
struct Foo {
    let int: Int
    let optionalInt: Int?
    let array: [Int]
    let url: URL
    let optionalUrl: URL?
}

このようなアウトプットが得られます。
※ formatterにかけた上なので実際にはネストの位置はズレる

import Foundation

extension Foo: Decodable {
    enum CodingKeys: String, CodingKey {
        case int
        case optionalInt
        case array
        case url
        case optionalUrl
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)

        self.int = try values.decode(Int.self, forKey: .int)
        self.optionalInt = try values.decodeIfPresent(Int.self, forKey: .optionalInt)
        self.array = try values.decode([Int].self, forKey: .array)
        let decodedUrl = try values.decode(String.self, forKey: .url)
        guard let convertedUrl = URL(string: decodedUrl) else {
            throw Error
        }
        self.url = convertedUrl
        let decodedOptionalUrl = try values.decodeIfPresent(String.self, forKey: .optionalUrl)
        let convertedOptionalUrl = decodedOptionalUrl.flatMap {
            URL(string: $0)
        }
        self.optionalUrl = convertedOptionalUrl
    }
}

自動化する

ツールの導入

Sourcery というツールを利用します。
Swiftのコードを解析、テンプレートにパースすることでSwiftのコードを自動生成するものです。
https://github.com/krzysztofzablocki/Sourcery

Homebrew, Mint, CocoaPods, バイナリでインストール可能です。
どの方法でも可能ですが、プロジェクトごとにツールのバージョン管理したいため以降はMintを利用する前提で記述します。

入力ファイルの用意

Decodableに準拠させたいファイルを Input/ に配置します。
自動生成の対象struct, classには /// sourcery: DecodableTarget というアノテーションを付けます。
自動化が難しく自前でデコード処理を書かなければいけない場合は、アノテーションを付けないことで自動生成の対象から外すことができます。

サンプルとして以下のstructを用意しました。

Input/Foo.swift
import Foundation

/// sourcery: DecodableTarget
struct Foo {
    let int: Int
    let optionalInt: Int?
    let array: [Int]
    let url: URL
    let optionalUrl: URL?
}

テンプレートファイルの用意

詳細はドキュメントを参照してください。
https://github.com/krzysztofzablocki/Sourcery/blob/master/guides/Writing templates.md

サンプルとして URL のみ対応していますが、他の任意の型も必要に応じて追加したり、生成方法を変更することができます。

template.stencil
import Foundation

{% for type in types.structs|annotated: "DecodableTarget" %}
extension {{ type.name }}: Decodable {
    enum CodingKeys: String, CodingKey {
        {% for variable in type.variables %}
        case {{ variable.name }}
        {% endfor %}
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)

        {% for variable in type.variables %}
            {% if variable.unwrappedTypeName == "URL" %}
                {% if variable.isOptional %}
                    let decoded{{ variable.name|upperFirstLetter }} = try values.decodeIfPresent(String.self, forKey: .{{ variable.name }})
                    let converted{{ variable.name|upperFirstLetter }} = decoded{{ variable.name|upperFirstLetter }}.flatMap { URL(string: $0) }
                {% else %}
                    let decoded{{ variable.name|upperFirstLetter }} = try values.decode(String.self, forKey: .{{ variable.name }})
                    guard let converted{{ variable.name|upperFirstLetter }} = URL(string: decoded{{ variable.name|upperFirstLetter }}) else {
                        throw Error // TODO: replace to some error
                    }
                {% endif %}
                self.{{ variable.name }} = converted{{ variable.name|upperFirstLetter }}
            {% else %}
                {% if variable.isOptional %}
                    self.{{ variable.name }} = try values.decodeIfPresent({{ variable.unwrappedTypeName }}.self, forKey: .{{ variable.name }})
                {% else %}
                    self.{{ variable.name }} = try values.decode({{ variable.typeName }}.self, forKey: .{{ variable.name }})
                {% endif %}
            {% endif %}
        {% endfor %}
    }
}

{% endfor %}

実行

.sourcery.yml に設定を記述することも可能ですが説明の簡略化のためコマンドラインオプションで記述します。
https://github.com/krzysztofzablocki/Sourcery#configuration-file

mint run sourcery \
         --sources Input \
         --templates template.stencil \
         --output Output.swift

出力結果

--output で指定したファイルに ゴール のコードが出力されます。

Discussion