この記事は?
この記事は Movable Type Advent Calendar 2022 24日目の記事です。
去年はバタバタしている間に枠が埋まっていたので「ああ、エントリーできないなぁ」なんて考えていたのですが、今年はえいやっと登録してみました。さてネタがないぞ、、、
ネタが無い場合は今やっている仕事から持ってくれば良いというのが通例(?)なので、最近やっている仕事の事を(可能な範囲で)お話ししつつ、あとはどこまで出来るかという時間との勝負といった感じで進めて行きたいと思います(毎年だなw)。
と書いてみた物の、実はこの書き出しは 2年前の自分の投稿 のオマージュになっております(誰得情報?)
簡単に自己紹介
- 元Six Apart社員
- TypePad(現Lekumo)チームで開発 ⇒ Movable Typeチームでドキュメント(プラグイン開発ガイド、MTMLガイド、等) ⇒ 退社してからはMTプラグイン構築 ⇒ 現在はMTからは遠ざかってます
- 今はSwift中心に、PHP(Laravel)やらJavaScript(node.js)やらFlutterやら雑食系エンジニアやってます
- 一昨年から2年ぶりにMovable Type Advent Calendarに参加
今やっている事、Swiftについて
仕事は今、某企業さんのiOSアプリの開発をSwiftを使ってやっています。一昨年の時点ではFlutterを使ってましたが、先々月からSwift使いになりました。まだまだ勉強することが多く、今回のAdvent Calendarも本来ならSwiftについて語れる程の知識を持ち合わせていないのですが、そこはいつもの強心臓で乗り切りたいと思います。
Flutterは1コードでiOS(iPadOS), Android, macOS, Windows, Linux, Webといったマルチユースができるのがウリのプラットフォームです。SwiftもApple製品に限られていますが、iOS, iPadOS, macOS, TV OS(Apple TV)のマルチユース(と言うのだろうか??)が出来るようになっています。ただSwiftは言語としてはWindowsやLinuxでも利用出来るので、左記はすこし語弊があります。ここではUIを含めた統合開発環境であるXcodeが利用でき、その上でiOS, iPadOS, macOS, TV OSの実行ファイルが作成出来る事を言っていると読み替えてください。
他のマルチプラットフォームに対応している物と言えば、先日Beta版になったKMM(Kotlin Multiplatform Mobile)があります。これはiOS(iPadOS)とAndroidのコアの部分を共通化し、UIの部分はそれぞれSwiftとKotlinで実装するという、Flutterとはちょっと違う方向の実装になっています。と、この話をし始めるとQiitaの投稿1つ分では収まらないので、Swiftに集中したいと思います。
SwiftはAppleがメインと位置づけている開発言語で、開発が始められたのは2010年。2014年のApple WWDCで一般に発表され、ユーザーが使い始めてから8年が経ちました(詳しくは Swift(プログラミング言語) by Wikipedia を読んでください)。それ以前に開発に利用されていたObjective-Cを自分も一時期使っていましたが、大変とっつきにくい印象がありSwiftの言語仕様に触れて「すんなり入ってくる!!」と思った物です。ただこれはあくまでSwiftの言語仕様についてであって、開発に用いられるStoryboardやSwiftUIの実装方法についてではありません。全く歯が立たない状態で日々苦労しています(苦笑)
どんな物を作るか?
Swift版のDataAPI SDKはSix Apart謹製の物が存在しています。しかしバージョンが古く、今のiOSにはそのままでは利用できなさそうです。試しに今のXcodeでSDKのソースコードを開いたところ、Swiftのエラーが出てしまってビルド出来ませんでした。自分のSwift力が足りないだけかも知れませんが、ここは作り直すくらいの勢いで行ってみたいと思います。
MovableType.netのMTDataAPIからのデータ取得の基本は 私が先日書いたQiita記事 を参照してみてください。
MTDataAPIのSwift用SDKは こちら 。
開発環境
- MacBook Pro 2019 : macOS Monterey 12.6.1
- Xcode 14.1
- AppCode 2022.2.4
OS
Monterey使ってますが、早いところVenturaにしたいところ。
IDE
Swiftでの開発は基本的にXcodeを使うのですが、私はIntelliJ IDEA系のIDEが染みついてしまっているので、部分的にAppCodeも利用しています。(しかし先日AppCodeの開発終了がアナウンスされました。残念、、、)
セットアップ
早速プロジェクトを作製します。今回はSwift Package
として作成するので、選択しNext
をクリックします。ライブラリの保存場所を聞かれるので、今回はMTDataAPI-SDK-Next
としました。
どんな基準で作成するか?
コードの作成を始める前に、どんな基準で今回のパッケージを作成するかを決めたいと思います。
- Swift Package Mangerでパッケージ管理をする(Good bye, CocoaPods!!)
- iOS 13, macOS 10.15 以降に対応
- AlamofireやSwiftyJSONといった外部パッケージは利用しない
- クロージャを使ったsuccess, failureを利用せず、CombineでAnyPublisher<T, Error>を用いる
- JSONのデコード、エンコードは標準のCodableを利用する
- Unitテストを書こう
以下でもうちょっと詳細に説明します。
Swift Package Mangerでパッケージ管理をする(Good bye, CocoaPods!!)
古くから使われていて、自分もFlutterで良くお世話になっているCocoaPods。これはパッケージ管理をするツールなのですが、AppleがSwift Package Mangerという謹製ツールを出してくれているので、そろそろ乗り換えても良いかなと思っています。共存もできるので、今後必要になれば追加すれば良いかなと考えています。
iOS 13, macOS 10.15 以降に対応
古いバージョンのiOS, macOSを切って、新しめの機能を使って実装していきたいと思います。今回は後述するCombineやCodableがそれに当たります。
AlamofireやSwiftyJSONといった外部パッケージは利用しない
HTTP通信を司るAlamofireと、JSONのエンコード・デコードを司るSwiftyJSONに現行のSDKは依存しています。もちろん利用しても良いのですが、Apple謹製で利用出来る物があればそちらを利用していきたいと思います。HTTP通信はURLSessionを、JSONのエンコード・デコードにはCodableというプロトコルを利用します。
クロージャを使ったsuccess, failureを利用せず、CombineでAnyPublisher<T, Error>を用いる
クロージャを使った非同期処理はどうしても記述が複雑になってしまいがちです。ここはApple純正のCombineを使った方法で書き換えたいと思います。
JSONのデコード、エンコードは標準のCodableを利用する
Swift標準のデコード、エンコードの書き方にCodableというプロトコル(Swift以外の言語で言うところのインターフェース)を利用します。Codableプロトコルに準拠した構造体は自動(もしくは半自動)でJSONとのエンコード、デコードを行えるようになります。
Unitテストを書こう
テストファーストで実装していきたいので、テストを積極的に書いていきたいと思います。
いざ実装
テストコード
まずはテストコードです。最初からこれを全部書いていた訳ではありませんが、だいたいこんな感じというイメージはありました。その最終形がこちらです。エンドポイントバージョンやAPI Base URLなどの動作は現行のDataAPI SDKに準拠した動作にしています。
import XCTest
import Combine
@testable import MTDataAPI_SDK_Next
final class DataAPITests: XCTestCase {
var api: DataAPI = DataAPI()
var cancellable = Set<AnyCancellable>()
// デフォルトのエンドポイントバージョンのチェック
func testShouldGetValidEndpointVersion() throws {
XCTAssertEqual(api.endpointVersion, "v3")
}
// デフォルトのAPI Base URLのチェック
func testShouldGetValidApiBaseURL() throws {
XCTAssertEqual(api.apiBaseURL, "http://localhost/cgi-bin/MT-6.1/mt-data-api.cgi")
}
// エンドポイントバージョンの書き換えのチェック
func testCanSetEndpointVersion() throws {
api.endpointVersion = "v4"
XCTAssertEqual(api.endpointVersion, "v4")
}
// API Base URLの書き換えのチェック
func testCanSetApiBaseURL() throws {
api.apiBaseURL = "https://movabletype.net/.data-api"
XCTAssertEqual(api.apiBaseURL, "https://movabletype.net/.data-api")
}
// 正常にVersionがデコードできるかのチェック
func testCanDecodeVersion() throws {
// 元々のJSON文字列
let json = """
{"endpointVersion":"v4","apiVersion":4}
"""
// JSON文字列から作製したDataオブジェクトインスタンス
let jsonData = json.data(using: .utf8)!
// デコード実装
let version = try JSONDecoder().decode(DataAPI.Version.self, from: jsonData)
// 値チェック
XCTAssertEqual(version, DataAPI.Version(endpointVersion: .init(rawValue: "v4"), apiVersion: .init(rawValue: 4)))
}
// 正常にVersionがエンコードできるかのチェック
func testCanEncodeVersion() throws {
// Versionオブジェクトインスタンス
let version = DataAPI.Version(endpointVersion: .init(rawValue: "v4"), apiVersion: .init(rawValue: 4))
// エンコード実装
let jsonData = try JSONEncoder().encode(version)
// Dataオブジェクトインスタンスから文字列へ変換
let json = String(data: jsonData, encoding: .utf8)!
// 値チェック(ここではjson文字列のなかに、特定の文字列が含まれているかをチェックしている)
XCTAssert(json.contains("\"apiVersion\":4"))
XCTAssert(json.contains("\"endpointVersion\":\"v4\""))
}
// API経由で正しいVersionインスタンスが取得出来るかのテスト
func testShouldGetValidVersionInstance() async throws {
let expectation = expectation(description: "testShouldGetValidVersionInstance")
StubURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/2", headerFields: ["Content-Type": "application/json"])!
let json = """
{"endpointVersion":"v4","apiVersion":4}
"""
let jsonData = json.data(using: .utf8)
return (response, jsonData)
}
let config = URLSessionConfiguration.default
config.protocolClasses = [StubURLProtocol.self]
let session = URLSession(configuration: config)
var apiClient = DataAPI(session: session)
apiClient.apiBaseURL = "https://www.example.com/.data-api"
apiClient.version()
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let e):
print(e.localizedDescription)
XCTFail()
}
}, receiveValue: { version in
// 値チェック
XCTAssertEqual(version, DataAPI.Version(endpointVersion: .init(rawValue: "v4"), apiVersion: .init(rawValue: 4)))
expectation.fulfill()
})
.store(in: &cancellable)
wait(for: [expectation], timeout: 10)
}
}
これに対して実装は以下のようになります。EntityとValueObjectをプロトコルにしてしまったのが、自分的にはちょっといけてないのですが、一旦はこちらで。
import Foundation
import Combine
public struct DataAPI {
// MARK: - Properties
let session: URLSession
public var endpointVersion = "v3"
public var apiBaseURL = "http://localhost/cgi-bin/MT-6.1/mt-data-api.cgi"
fileprivate var apiURL: String {
"\(apiBaseURL)/\(endpointVersion)"
}
public init(session: URLSession = URLSession(configuration: .ephemeral)) {
self.session = session
}
}
// MARK: - Enums
extension DataAPI {
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
}
// MARK: - Entities
fileprivate protocol Entity: Codable, Equatable {
}
extension DataAPI {
public struct Version: Entity {
public let endpointVersion: DataAPI.EndpointVersion
public let apiVersion: APIVersion
}
}
// MARK: - ValueObjects
fileprivate protocol ValueObject: Codable, Equatable, RawRepresentable {
associatedtype AssociatedType
var rawValue: AssociatedType { get }
init(rawValue: AssociatedType)
}
fileprivate extension ValueObject where Self.AssociatedType: CustomStringConvertible {
var description: String {
return rawValue.description
}
}
extension DataAPI {
public struct EndpointVersion: ValueObject {
public let rawValue: String
public init(rawValue: String) {
self.rawValue = rawValue
}
}
public struct APIVersion: ValueObject {
public let rawValue: Float
public init(rawValue: Float) {
self.rawValue = rawValue
}
}
}
// MARK: - Methods
extension DataAPI {
fileprivate func actionCommon<T: Decodable>(_ method: HTTPMethod, url: URL) -> AnyPublisher<T, Error> {
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
return session.dataTaskPublisher(for: request)
.map(\.data)
.decode(type: T.self, decoder: JSONDecoder())
.eraseToAnyPublisher()
}
fileprivate func get<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
return actionCommon(.get, url: url)
}
}
// MARK: - API Methods
extension DataAPI {
// MARK: - version
public func version() -> AnyPublisher<Version, Error> {
guard let url = URL(string: apiBaseURL + "/version") else {
return Fail(error: NSError(domain: "Invalid version API URL", code: -1)).eraseToAnyPublisher()
}
return get(url)
}
}
さて、これで終わり。ではつまらないので、簡単なサンプルコードを書いて実際の動きを見てみたいと思います。コマンドラインツールのプロジェクトを作製し、main.swiftをversion.swiftとリネームした上で以下のようなコードを記述します。
import Foundation
import Combine
import MTDataAPI_SDK_Next
@main
public struct FetchVersion {
public static func main() {
var cancellable = Set<AnyCancellable>()
var apiClient = DataAPI()
apiClient.apiBaseURL = "https://movabletype.net/.data-api"
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
apiClient.version()
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
print("finished!!")
dispatchGroup.leave()
break
case .failure(let e):
print(e.localizedDescription)
dispatchGroup.leave()
break
}
}, receiveValue: { version in
print("EndpointVersion : \(version.endpointVersion.rawValue)")
print("ApiVersion : \(version.apiVersion.rawValue)")
})
.store(in: &cancellable)
dispatchGroup.wait()
}
}
こちらを実行すると以下のような出力結果が得られます。
試しに、 https://movabletype.net/.data-api/version にアクセスしてみます。
{"endpointVersion":"v4","apiVersion":4}
API経由でendpointVersionとapiVersionが取得出来ている事が分かります。JSONを見ると、apiVersionは整数のように見えますが、DataAPIのリファレンスを見ると、実数(Float)になっていたのでこれが正解のようです。
最後に
今回もバージョン番号を取得するところまでしか実装できませんでした。時間を作って完走したいところですが、年末年始に少しずつ進めたいと思います。今回の成果物は以下のGitHubリポジトリで公開しています。