はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 5 日目の記事です。
5 日目はアプリからデータ登録とデータ取得をできるよう通信まわりの実装をします。
個人開発ではそこまでやる必要はないのですが下記 3 つを切り替えられるようにします。
- 本番環境
- 開発環境
- ローカルの JSON ファイル読み込み
ローカルの JSON ファイル読み込みはサーバーサイドの実装がまだ終わってない場合にアプリの実装を進めたいときや通信せずにアプリの動作だけみたいときなどに利用します(私はこの手法をよく使います)。
データ定義
家計簿のデータを扱うためにまずはデータクラスをつくります。
Storage に SwiftData を選択したら自動で生成されている Item
クラスを下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
import Foundation import SwiftData @Model final class Item: Codable { /// ID @Attribute(.unique) var id: String /// 購入日 var date: Date /// 品名 var name: String /// 価格 var price: Int /// 項目 var categoryValue: Int /// サブ項目 var categorySubValue: Int? init(id: String, date: Date, name: String, price: Int, categoryValue: Int, categorySubValue: Int?) { self.id = id self.date = date self.name = name self.price = price self.categoryValue = categoryValue self.categorySubValue = categorySubValue } // MARK: - Codable enum CodingKeys: CodingKey { case id case date case name case price case category case categorySub } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) date = try container.decode(Date.self, forKey: .date) name = try container.decode(String.self, forKey: .name) price = try container.decode(Int.self, forKey: .price) categoryValue = try container.decode(Int.self, forKey: .category) categorySubValue = try? container.decode(Int.self, forKey: .categorySub) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(id, forKey: .id) try container.encode(date, forKey: .date) try container.encode(name, forKey: .name) try container.encode(price, forKey: .price) try container.encode(categoryValue, forKey: .category) try container.encode(categorySubValue, forKey: .categorySub) } } |
リクエスト生成
次に HTTP リクエストの生成関連を実装していきます。
AppConfig.swift ファイルを作成し下記の実装をします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
import Foundation struct AppConfig { enum Environment { /// 本番 case release /// 開発 case development /// ダミー case dummy } let environment: Environment /// 本番環境かどうか var isRelease: Bool { switch environment { case .release: return true default: return false } } } |
AppConfig はその名の通りアプリの設定関連のもので本番、開発、ダミーを切り替えるために使います。
次に API.swift ファイルを作成し下記の実装をします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
import Foundation enum HTTPMethod: String { case get = "GET" case post = "POST" } protocol API { func makeURLRequest(config: AppConfig) -> URLRequest func makeBaseURL(config: AppConfig) -> URL var queryItems: [String: Any] { get } var httpMethod: HTTPMethod { get } var httpBody: Data? { get } var timeout: TimeInterval { get } var accept: String { get } var contentType: String { get } } /// リクエストのデフォルト値 enum APIDefaultValue { static let timeout: TimeInterval = 30 static let accept: String = "application/json" static let contentType: String = "application/json" } extension API { func makeURLRequest(config: AppConfig) -> URLRequest { var components = URLComponents(url: makeBaseURL(config: config), resolvingAgainstBaseURL: true)! var request = URLRequest(url: components.url!, timeoutInterval: timeout) switch httpMethod { case .get: components.queryItems = queryItems.map { .init(name: $0, value: $1 as? String) } case .post: request.setValue(contentType, forHTTPHeaderField: "Content-Type") request.httpBody = httpBody } request.httpMethod = httpMethod.rawValue request.setValue(accept, forHTTPHeaderField: "Accept") request.url = components.url return request } var timeout: TimeInterval { return APIDefaultValue.timeout } var accept: String { return APIDefaultValue.accept } var contentType: String { return APIDefaultValue.contentType } } |
API は HTTP リクエストのタイムアウトなどの設定をするために使います。AppConfig と API は家計簿アプリ以外でも使える汎用的なやつです。
次に APIError.swift ファイルを作成し下記の実装をします。
1 2 3 4 5 6 7 8 9 10 |
import Foundation enum APIError: Error { /// 通信エラー case network /// サーバーエラー case server /// JSON 不正 case invalidJSON } |
APIError は API のエラーを定義したものです。上記以外にもエラーがあればここに追加していきます。
次に .gitignore に Secret.swift を追加して Secret.swift ファイルを作成し以下のように API の URL を記載します。
1 2 3 |
import Foundation let secretBaseURL = "https://example.com" |
これで GitHub などに公開しても URL は公開しなくなります。Secret.swift には API キーなど公開したくない値を記載します。
次に ExpenseLogAPI.swift ファイルを作成し下記の実装をします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
import Foundation /// 家計簿API enum ExpenseLogAPI { /// データ取得 case fetch(year: String, month: String) /// データ登録 case register(items: [Item]) } extension ExpenseLogAPI: API { func makeBaseURL(config: AppConfig) -> URL { if config.isRelease { return .init(string: secretBaseURL)! } return .init(string: secretBaseURL)! } var queryItems: [String : Any] { switch self { case .fetch(let year, let month): return [ "year": year, "month": month ] case .register: return [:] } } var httpMethod: HTTPMethod { switch self { case .fetch: return .get case .register: return .post } } var httpBody: Data? { switch self { case .fetch: return nil case .register(let items): let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(dateStrategyFormatter) return try? encoder.encode(items) } } } extension ExpenseLogAPI { var dateStrategyFormatter: DateFormatter { let dateFormatter = DateFormatter() dateFormatter.calendar = Calendar(identifier: .gregorian) dateFormatter.dateFormat = "yyyyMMdd" return dateFormatter } var dateDecodingStrategy: JSONDecoder.DateDecodingStrategy? { return .formatted(dateStrategyFormatter) } } |
ExpenseLogAPI は家計簿アプリ専用の API を記載します。削除や更新処理など API を追加したい場合はここに記載します。
下記の makeBaseURL で本番環境と開発環境の接続先変更が可能です。
1 2 3 4 5 6 |
func makeBaseURL(config: AppConfig) -> URL { if config.isRelease { return .init(string: secretBaseURL)! } return .init(string: secretBaseURL)! } |
これで API のリクエスト生成処理は完成です。timeout などの設定値は以下のようにすれば API ごとに設定できるようになっています。
1 2 3 4 5 6 7 8 |
var timeout: TimeInterval { switch self { case .fetch: return 60 default: return APIDefaultValue.timeout } } |
通信処理
通信処理を実装していきます。
本番・開発環境
まずは本番・開発環境から実装していきます。
Session.swift ファイルを作成し下記の実装をします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
import Foundation protocol Session { var config: AppConfig { get } func send(api: API, completion: @Sendable @escaping (Result<Data, APIError>) -> Void) } final class DefaultSession: Session { private let urlSession: URLSession let config: AppConfig init(urlSession: URLSession = .shared, config: AppConfig) { self.urlSession = urlSession self.config = config } func send(api: API, completion: @Sendable @escaping (Result<Data, APIError>) -> Void) { let task = urlSession.dataTask(with: api.makeURLRequest(config: config)) { data, response, error in if let error = error { print(error) completion(.failure(.network)) return } if let response = response as? HTTPURLResponse, response.statusCode != 200 { completion(.failure(.server)) return } if let data = data { completion(.success(data)) return } completion(.failure(.server)) } task.resume() } } |
Session と DefaultSession は家計簿アプリ以外でも使える汎用的なものです。
次に ExpenseLogAPIClient.swift ファイルを作成し下記の実装をします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
import Foundation final class ExpenseLogAPIClient { private let session: Session init(session: Session) { self.session = session } private func send<T: Codable>(api: ExpenseLogAPI, completion: @escaping @Sendable (Result<T, APIError>) -> Void) { let dateDecodingStrategy = api.dateDecodingStrategy session.send(api: api) { result in switch result { case .success(let data): let decoder = JSONDecoder() if let dateDecodingStrategy = dateDecodingStrategy { decoder.dateDecodingStrategy = dateDecodingStrategy } do { let response = try decoder.decode(T.self, from: data) completion(.success(response)) } catch let error { print(error) completion(.failure(.invalidJSON)) } case .failure(let error): completion(.failure(error)) } } } } extension ExpenseLogAPIClient { /// 指定の年月の家計簿データ取得処理 /// - Parameters: /// - year: 年(yyyy形式) /// - month: 月(M形式、範囲は1~12) func fetch(year: String, month: String, completion: @escaping @Sendable (Result<[Item], APIError>) -> Void) { send(api: .fetch(year: year, month: month), completion: completion) } /// 家計簿データ登録処理 /// - Parameters: /// - items:登録する家計簿データ func register(items: [Item], completion: @escaping @Sendable (Result<[Item], APIError>) -> Void) { send(api: .register(items: items), completion: completion) } } |
ExpenseLogAPIClient は家計簿アプリ専用のクラスです。API を追加する場合はここに追加していきます。
これで本番・開発環境は完成です。
ダミー環境
次にダミー環境を作成します。Resources フォルダに fetch.json と register.json を以下のように作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
[ { "id":"100000BF-4AAC-48A6-B316-8A533358859B", "date":"20240122", "name":"コーヒー", "price":178, "category":0, "categorySub":1 }, { "id":"200000BF-4AAC-48A6-B316-8A533358859B", "date":"20240110", "name":"紅茶", "price":200, "category":0, "categorySub":1 }, { "id":"300000BF-4AAC-48A6-B316-8A533358859B", "date":"20240112", "name":"コーラ", "price":200, "category":0, "categorySub":1 }, { "id":"400000BF-4AAC-48A6-B316-8A533358859B", "date":"20240115", "name":"ラーメン", "price":1500, "category":0, "categorySub":0 } ] |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
[ { "id":"444444BF-4AAC-48A6-B316-8A533358859B", "date":"20240122", "name":"コーヒー", "price":178, "category":0, "categorySub":1 }, { "id":"555555BF-4AAC-48A6-B316-8A533358859B", "date":"20240210", "name":"紅茶", "price":200, "category":0, "categorySub":1 } ] |
それぞれダミー環境での取得・登録処理の返却値になります。
次に DummySession.swift ファイルを作成し下記の実装をします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import Foundation final class DummySession: Session { let config: AppConfig = .init(environment: .dummy) func send(api: API, completion: @Sendable @escaping (Result<Data, APIError>) -> Void) { guard let expenseLogAPI = api as? ExpenseLogAPI, let data = makeData(api: expenseLogAPI) else { assertionFailure("ダミー用データを用意してください") completion(.failure(.server)) return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { completion(.success(data)) } } private func makeData(api: ExpenseLogAPI) -> Data? { let fileName: String switch api { case .fetch: fileName = "fetch" case .register: fileName = "register" } guard let url = Bundle.main.url(forResource: fileName, withExtension: "json") else { return nil } return try? Data(contentsOf: url) } } |
遅延処理を入れてるのは通信処理ぽくするためです。これでローカルの JSON ファイルを読み込んで API ごとに返却できるようになりました。JSON ファイルではなく画像ファイルなどを返したい場合は下記の処理を API ごとに変えてやれば可能です。
1 2 3 |
guard let url = Bundle.main.url(forResource: fileName, withExtension: "json") else { return nil } |
これでダミー環境も完成です。
環境ごとの切替
最後に環境ごとの切り替えができるようにします。APISessionKey.swift ファイルを作成し下記の実装をします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import SwiftUI let environment: AppConfig.Environment = .release struct APISessionKey: @preconcurrency EnvironmentKey { @MainActor static let defaultValue: ExpenseLogAPIClient = environment == .dummy ? .init(session: DummySession()) : .init(session: DefaultSession(config: .init(environment: environment))) } extension EnvironmentValues { var apiClient: ExpenseLogAPIClient { get { self[APISessionKey.self] } set { self[APISessionKey.self] = newValue } } } |
これで各 View から @Environment にアクセスできるようになりました。環境を切り替えたいときは下記の値を書き換えます。
1 |
let environment: AppConfig.Environment = .release |
今回は手動で書き換えるようにしましたが下記みたいにスキームをわけてもいいでしょう。
動作確認
動作確認をします。ContentView.swift を下記のように修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
struct ContentView: View { @Environment(\.apiClient) private var apiClient var body: some View { VStack { Button("データ取得") { apiClient.fetch(year: "2024", month: "1") { result in switch result { case .success(let items): print(items) case .failure(let error): print(error) } } } Button("データ登録") { let item = Item(id: UUID().uuidString, date: Calendar(identifier: .gregorian).date(from: DateComponents(year: 2024, month: 1, day: 10))!, name: "パン", price: 100, categoryValue: 0, categorySubValue: 0) apiClient.register(items: [item]) { result in switch result { case .success(let items): print(items) case .failure(let error): print(error) } } } } } } |
「データ取得」ボタン、「データ登録」ボタン押下でそれぞれデータ取得とデータ登録ができていれば成功です。
おわりに
これでアプリからデータをさわれるようになりました!
通信周りは未だにどう実装すべきなのか迷いますがわりと汎用的なものになったんではないでしょうか。どうやって使いやすく実装するのか考えるのは楽しいのでみなさんもぜひ色々試してみてください。
明日は画面構成について書きます。
コメント