LoginSignup
11
2

More than 5 years have passed since last update.

Codableに対応したMessagePackのエンコーダー/デコーダーを作った際に得た知見

Posted at

弊社では、一部のサービスでMessagePackを利用しています。
Swift製のMessagePackエンコーダー/デコーダーのライブラリは存在するのですが、Swift4から利用できるCodableに対応した、いい感じのライブラリがなかったので、Codableに対応したMessagePackのエンコーダー/デコーダーを作ってみました。

この記事では、MessagePackについて軽く説明した後、Codableに対応したカスタムエンコーダー/デコーダーを実装する上で得た知見を共有できればと思っています。

MessagePackとは

MessagePackは性能を重視したバイナリベースのシリアライズ形式です。
JSONのように汎用的なシリアライズ形式でありつつ、バイナリベースで高速です。

MessagePackの特徴

  • シリアライズ/デシリアライズがとても高速
  • シリアライズされたデータのサイズが小さい
  • フォーマット定義(IDL)が不要
  • ストリーム処理できる

http://frsyuki.hatenablog.com/entry/20080816/p1 より

MessagePackの仕様

  • 基本的にFirst byteがデータ型を表し、後ろにデータが続く形式
  • バイトオーダーはビックエンディアン

詳しい仕様はGithubに上がっていますので興味がある方はこちらを参照ください。
https://github.com/msgpack/msgpack/blob/master/spec.md

Codableとは

Swift4から採用されたプロトコルで、Codableに準拠するとエンコード/デコードが可能であるということを宣言でき、コンパイラが必要なコードを自動で補完してくれます。
Codableは、EncodableとDecodableのtypealiasとして定義されています。

typealias Codable = Encodable & Decodable

protocol Encodable {
    func encode(to encoder: Encoder) throws
}

protocol Decodable {
    init(from decoder: Decoder) throws
}

Codableのメリット

Codableに準拠している型は、Codableに対応したエンコーダー/デコーダーを利用するとエンコード/デコード処理が容易になります。

Swiftには標準で JSONEncoder , JSONDecoder が存在します。

let input = Landmark(name: "Mojave Desert")
// Landmark -> JSON
let data = try! JSONEncoder().encode(input)
// JSON -> Landmark
let landmark = try! JSONDecoder().decode(Landmark.self, from: data)

また、シリアライズ形式が変わった場合は、エンコーダー/デコーダーを変えるだけで対応可能です。

let input = Landmark(name: "Mojave Desert")
// Landmark -> MessagePack
let data = try! MessagePackEncoder().encode(input)
// MessagePack -> Landmark
let landmark = try! MessagePackDecoder().decode(Landmark.self, from: data)

カスタムエンコーダー/デコーダーの基本

ここからは、カスタムエンコーダー/デコーダーを実装するための基本的な部分を見ていきます。
エンコーダーとデコーダーは似たような形になるので、ここではエンコーダーを例にあげます。

エンコーダーの実装に必要なプロトコル

エンコーダーを実装するにあたり、次のプロトコルに準拠する型を定義する必要があります。

KeyedEncodingContainer は、正確にいうとプロトコルではありません。
これは、KeyedEncodingContainer が準拠している KeyedEncodingContainerProtocolがassociatedtypeを持つため、Type Erasureが使われています。
KeyedEncodingContainerの型を実装する際は、実質 KeyedEncodingContainerProtocol に準拠する形になります。

public protocol Encoder {
    public var codingPath: [CodingKey] { get }

    public var userInfo: [CodingUserInfoKey : Any] { get }

    public func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey

    public func unkeyedContainer() -> UnkeyedEncodingContainer

    public func singleValueContainer() -> SingleValueEncodingContainer
}

Encoder のメソッドはエンコード対象の型により呼ばれるメソッドが変わります。
よって、使用される XXXEncodingContainer プロトコルは各型と次のような対応関係にあります。

Protocol Type
KeyedEncodingContainer Dictionary
UnkeyedEncodingContainer Array
SingleValueEncodingContainer Optional, Bool, String, Double, Float, Int, Int8, Int16, Int32, Int64, UInt, UInt8, UInt16, UInt32, UInt64 And Custom type

これらのプロトコルに準拠した型を定義し、エンコード処理を書いていくのが基本的な実装になります。

また、 必須ではないですが、標準のJSONEncoderPropertyListEncoderを見るとエンコードしたデータを保持しておく XXXStrage も必要になってくるのがわかります。

詳しい実装内容は、それらのEncoderやMessagePackEncoderの実装を見てみてください。

カスタムエンコーダー/デコーダー実装時に注意が必要な点

エンコーダーの対象外な型(Date/Data型など)

現在エンコーダーで対応できる型は次のようになっています。

Type
Bool
String
Double
Float
Int
Int8
Int16
Int32
Int64
UInt
UInt8
UInt16
UInt32
UInt64

(要素がCodableに準拠している場合の Dictionary , Array , Optional

ですが、Date/Data型はCodableに準拠しているのでエンコード/デコード可能です。
なぜでしょうか?

実装をみてみましょう。

extension Date : Codable {
    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(self.timeIntervalSinceReferenceDate)
    }
}

https://github.com/apple/swift/blob/6e7051eb1e38e743a514555d09256d12d3fec750/stdlib/public/Darwin/Foundation/Date.swift#L290-L301

Date型の encodeでは、timeIntervalSinceReferenceDateがエンコード対象となっています。
timeIntervalSinceReferenceDateは、 TimeInterval型で TimeIntervalDouble型のエイリアスなので、実質 Doubleとして処理されます。

Data型の実装もみてみましょう。

extension Data : Codable {
    var container = encoder.unkeyedContainer()
    try withUnsafeBytes { (buffer: UnsafeRawBufferPointer) in
        try container.encode(contentsOf: buffer)
    }
}

https://github.com/apple/swift/blob/d2da2080b709ca6f37d428b188d4d61d93bcc1d9/stdlib/public/Darwin/Foundation/Data.swift#L2793-L2823

Data型では、バイト配列に変換され、UnkeyedEncodingContainer が使用されています。
よってData型は、 [UInt8] として処理されます。

これが意図した結果であればいいのですが、違う処理に変えたい場合は一工夫必要です。

JSONEncoder では Date/Data型にキャストし、それぞれ適切な処理に通しています。

fileprivate func box(_ date: Date) throws -> NSObject {
    ...
}

fileprivate func box(_ data: Data) throws -> NSObject {
    ...
}

fileprivate func box_<T : Encodable>(_ value: T) throws -> NSObject? {
    if T.self == Date.self {
        return try self.box((value as! Date))
    } else if T.self == Data.self {
        return try self.box((value as! Data))
    }
}

DictionaryのキーがString or Int型じゃない場合

Swiftでは、Hashable に準拠していれば Dictionary のキーとして利用することができます。
この場合エンコード処理では、String or Int の場合と挙動が変わってきます。

public func encode(to encoder: Encoder) throws {
    if Key.self == String.self {
      var container = encoder.container(keyedBy: _DictionaryCodingKey.self)
      for (key, value) in self {
        let codingKey = _DictionaryCodingKey(stringValue: key as! String)!
        try container.encode(value, forKey: codingKey)
      }
    } else if Key.self == Int.self {
      var container = encoder.container(keyedBy: _DictionaryCodingKey.self)
      for (key, value) in self {
        let codingKey = _DictionaryCodingKey(intValue: key as! Int)!
        try container.encode(value, forKey: codingKey)
      }
    } else {
      var container = encoder.unkeyedContainer()
      for (key, value) in self {
        try container.encode(key)
        try container.encode(value)
      }
    }
  }
}

https://github.com/apple/swift/blob/master/stdlib/public/core/Codable.swift.gyb#L1820-L1857

String or Int の場合は、 KeyedEncodingContainer が使われているのに対し、それ以外では UnkeyedEncodingContainer が使用されています。
この辺を理解していないと意図した挙動をしない場合があるかもしれません。

superEncoder

KeyedEncodingContainerUnkeyedEncodingContainer に準拠した型には、 superEncoder メソッドを実装する必要があります。
これは継承関係にある型をエンコードする際に利用します。

class Animal: Codable {
    var legCount: Int

    private enum CodingKeys: String, CodingKey {
        case legCount
    }

    init(legCount: Int) {
        self.legCount = legCount
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(legCount, forKey: .legCount)
    }
}

class Dog: Animal {
    var name: String

    private enum CodingKeys: String, CodingKey {
        case name
    }

    required init(legCount: Int, name: String) {
        self.name = name
        super.init(legCount: legCount)
    }

    override func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        // ↓↓↓↓ ここ
        let superEncoder = container.superEncoder()
        try super.encode(to: superEncoder)
    }
}

自分が実装する際に superEncoderがどのような場面で利用されるのかよくわからなかったので、???って感じだったのですが、このstack overflowのページで理解することができました:clap::clap::clap:

さいごに

この記事が、カスタムエンコーダー/デコーダーを実装する際の手助けになれば幸いです。

MessagePackerを実装するにあたり、JSONEncoderMoreCodableの実装をとても参考にさせていただきました。ありがとうございます:pray:

もしiOSでMessagePackを利用する機会があれば、是非 MessagePackerを検討してみてください:wave:

11
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
2