Go
iOS
Swift
Alamofire
SwiftyJSON
FOLIODay 20

iOSアプリ(Swift + Go)通信処理チュートリアル

はじめに

こんにちは、株式会社FOLIOという証券会社で開発の方お手伝いさせていただいている@daihaseと申します。

こちらはFOLIO Advent Calendar 20日目の記事になります。 前回はよこなさんによるTrailblazerのReformを使ってRailsのフォームとモデルを分離する でした。

自分はQiita自体2回目の執筆で、前回から1年ぶりくらいなのでどんだけ空いてるんだというのもありますが、少しでも開発に詰まっている方の力になれればと書いてみました。

今回のタイトルにもあります「iOSアプリ開発においての通信処理」、周りの知り合いでもiOSアプリを開発している方は多いのですが、かれこれ数年開発していて何十本とアプリを出しているけど、「通信周りはちょっと...」という方が結構います。

その多くが本業としてiOS開発を行なっていなかったり、または会社勤めではなく完全な一人個人開発として制作しているのでそもそもサーバーとかあまりよくわからない、と言った感じでした。そこで今回分量の問題もあるので実際にサーバーを立ててとまでは行きませんが、ローカルにそれと同じような環境を用意し、実際にSwiftを使って通信処理を行ない、クライアント/サーバー間の連携をするサンプルを書いてみたので、そちらで少しでも体感いただければと思います。

iOS開発環境構築

ざっくりと、以下のような環境で開発を行います。

  • 開発マシン (Mac MacOS Sierra)
  • Xcode9.1
  • Swift4
  • 使用ライブラリ
    • Alamofire
    • SwiftyJSON
    • SVProgressHUD

  
  
それでは早速iOS側の実装から初めてみます。

プロジェクトの作成

まずプロジェクトを作成する必要があるのでXcodeを起動し、NetworkViewerとプロジェクト名をつけて保存しましょう。

プロジェクトが作成出来たら、次はライブラリを導入するので一度Xcodeを終了させます。

ライブラリの導入

ターミナルを開いて、以下を順に実行しCocoaPodsをインストールします。

$ sudo gem update —system
$ sudo gem install cocoapods
$ pod setup

  
これで導入準備完了なので、早速必要なライブラリを入れて行きます。
Xcodeで作成したプロジェクトの直下まで移動します。

$ cd workspace/NetworkViewer

移動出来たら以下のコマンドを実行し、Podsファイルを作成します。

$ pod init

実行すると、ディレクトリ内にPodfileというファイルが作成されていると思うので、そちらを開いて以下のように追記してください。

Podfile
# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'NetworkViewer' do
  use_frameworks!
     pod 'Alamofire', '~> 4.5'
     pod 'SwiftyJSON'
     pod 'SVProgressHUD'
  target 'NetworkViewerTests' do
    inherit! :search_paths
  end

  target 'NetworkViewerUITests' do
    inherit! :search_paths
  end
end

追記が終わりましたら、再びターミナルで以下を実行してください。

$ pod install

ズラズラとメッセージが出て、最終的にworkspaceというファイルが作られます。CocoaPodsを使ったアプリケーションはいつものxcodeprojではなく、xcworkspaceから起動し開発を行います。

それではxcworkspaceを開いてみましょう。
※最初はエラーが出ることがあるので起動したらビルドをしてみてください。

画面の作成

Main.storyboardがあるので、そちらのViewController上に以下のような画面を作成します。プロフィール画像の部分はあくまで雰囲気なので(実際通信で取ってきたりはしません)、何か適当な画像をご用意ください。

スクリーンショット 2017-12-17 12.37.30.png

あとは名前や自己紹介などのUILabelをUIViewController.swiftに対してアウトレット接続してやります。  

クライアントサイドの実装  

それではネットワーク接続周りに必要なファイルを作って行きますが、こうした方がいいという決まりは特になく、自分がよくやっていた手法の1例ですので軽い感じでみていただければと。

まず導入したライブラリのAlamofireですが、こちらはHTTPネットワークライブラリの1つで、Swiftを使った通信系アプリではほぼ定番というくらい使われています。今回もそれを使ってAPIを叩けるようにします。 SwiftyJSON、こちらはAPIを実行しJSON形式で返ってきた値を簡単に扱えるようにするライブラリです。最後にSVProgressHUDですが、通信時にクルクルとローディングが表示されるアプリが多いと思いますが、そうしたものを簡単に設定出来るライブラリです。

今回はそれらをそのままViewControllerなどにゴリゴリと書いて使うのではなく、ある程度規模が大きくなっても対応していけるよう設計してみます。

まずXcode上で「Network」というフォルダを作り、その中に

  • ApiPath.swift
  • NetworkLayer.swift
  • JsonParser.swift

と3つ用意します。

順に見て行きます。

ApiPath.swift
import Foundation

let Domain = "http://localhost:8080"

public protocol TargetType {
    var domain: String { get }
    var path: String { get }
    var method: String { get }
}

// 実行されるAPIの種類を管理
public enum API {
    // プロフィール取得API
    case profile(Int)
}

extension API: TargetType {
    public var domain : String {
        return Domain
    }

    public var path : String {
        switch self {
        // プロフィール取得
        case .profile(let user_id):
            return "\(domain)" + "/user/\(user_id)"
        }
    }

    public var method: String {
        switch self {
        case .profile:
            return "GET"
        }
    }
}

サーバーサイドの開発に慣れている方だと見てパッとイメージが沸くかと思いますが、APIを叩く際のエンドポイントや対応するメソッドなどをまとめたファイルになります。今回はプロフィールを取得するAPIだけなのでそれぞれ1つだけですね。

次はAlamofireを薄くラップしたファイルNetworkLayer.swiftを見てみます。

NetworkLayer.swift
import UIKit
import SwiftyJSON
import Alamofire

typealias NetworkStartHandler = ()->()
typealias NetworkErrorHandler = (NSError)->()
typealias NetworkFinishHandler = (Any?)->()

class NetworkLayer: NSObject {
    var start : NetworkStartHandler?
    var error : NetworkErrorHandler?
    var finish : NetworkFinishHandler?

    var api: API!
    var parameters: [String : Any]?
    var headers: [String : String]?

    var alamofireManager : Alamofire.SessionManager?
    var request : DataRequest?

    // 通信開始
    func setStartHandler(_ start: @escaping NetworkStartHandler) {
        self.start = start
    }

    // 通信エラー
    func setErrorHandler(_ error: @escaping NetworkErrorHandler) {
        self.error = error
    }

    // 通信終了
    func setFinishHandler(_ finish: @escaping NetworkFinishHandler) {
        self.finish = finish
    }

    fileprivate func sessionConfiguration() {
        self.alamofireManager = Alamofire.SessionManager.default
    }

    func requestApi(api: API, parameters: [String : AnyObject]?, headers: [String: String]?) {
        self.showApiLog(api)
        self.sessionConfiguration()

        self.api = api
        self.headers = headers
        if let param = parameters {
            self.parameters = param
        } else {
            self.parameters = nil
        }

        self.start?()

        self.request = alamofireManager?.request(
            api.path,
            method: self.getMethod(api),
            parameters: self.parameters,
            encoding: JSONEncoding.default
            ).responseJSON { (response: DataResponse<Any>) -> Void in
                switch(response.result) {
                case .success(let json):
                    print(json)
                    let response = JsonParser.sharedInstance.parseJson(api, json: JSON(json))
                    self.finish?(response)
                case .failure(let error):
                    print(error)
                    self.error?(error as NSError)
                }
        }
    }

    func getMethod(_ api : API) -> Alamofire.HTTPMethod {
        let requestMethod : Alamofire.HTTPMethod
        switch api.method {
        case "POST":
            requestMethod = .post
            break
        case "PUT":
            requestMethod = .put
            break
        case "DELETE":
            requestMethod = .delete
        case "PATCH":
            requestMethod = .patch
        default:
            requestMethod = .get
            break
        }
        return requestMethod
    }

    func showApiLog(_ api: API) {
        let now = Date()
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
        let dateString = formatter.string(from: now)
        print("***************************************")
        print("Request_url: \(api.path)")
        print("Date: \(dateString))")
        print("***************************************")
    }
}

こちらはAlamofireのrequestメソッドに対して、一枚被せているrequestApiメソッドの引数を元に処理を行います。こういうのを1つ作ってしまえば、APIを実行する各ファイルにAlamofireのrequestメソッドをエンドポイントごとに書いていくといったことをしなくてもいいので後々開発が楽です。

真ん中より少し下のAlamofireのrequestメソッド部分、responseというのがAlamofireを通して通信し返ってきた結果で、内部ではResult型の.success.failerを値にもっています。

そこで成功した時はcase .success(let json):以下でjsonをSwiftyJSONのJSON型に変換、パーサーの役目をする関数へ渡しています。その結果のレスポンスをクロージャーの引数に渡し実行することで、呼び出し側は通信結果の値を取得することが出来ます。失敗時も同じようにerror情報を渡したクロージャーを実行します。

それでは最後に、AlamofireからJSON型に変換された値がどのようにパースされていくかを見て行きます。

JsonParser.swift
import UIKit
import SwiftyJSON

class JsonParser: NSObject {
    class var sharedInstance: JsonParser {
        struct Static {
            static let instance: JsonParser = JsonParser()
        }
        return Static.instance
    }

    // 叩くAPIによってparseするタイプを出しわけ
    func parseJson(_ api: API, json: JSON) -> Any? {
        switch api {
        case .profile:
            return self.parseProfileApiJson(json)
        }
    }

    // プロフィール画面ユーザー情報取得
    private func parseProfileApiJson(_ json: JSON) -> UserModel {
        let userModel = UserModel()
        userModel.id = json["id"].int
        userModel.name = json["name"].string
        userModel.email = json["email"].string
        userModel.introduction = json["introduction"].string
        userModel.date = json["date"].string

        return userModel
    }
}

こちらは今回APIが1つなので1メソッドだけですが、エンドポイントの分だけ増えていくことになります。先ほどのAlamofireのコードから実行されるparseJsonメソッド内にて、渡ってきたapiの値によって呼び出し先を切り替える感じですね。jsonもセットすることでその先でパースもしてやれます。

parseProfileApiJsonメソッド内ではモデルに対して1つずつ値を格納していっています。ここで使っているUserModelは以下のように別ファイルで定義しておきます。XcodeでModelというディレクトリを用意し、その下にUserModel.swiftを作成します。中身は以下のようなシンプルな形になります。

UserModel.swift
import Foundation

class UserModel: NSObject {
    var id: Int?
    var name: String?
    var email: String?
    var introduction: String?
    var date: String?
}

  
  
こうして値がセットされたモデルが先ほどのAlamofireのコードにあるようにクロージャーのfinishに渡されます。

 let response = JsonParser.sharedInstance.parseJson(api, json: JSON(json))
                    self.finish?(response)

  

一気に裏っかわの通信部分を作ったので、今度はようやくそれらを画面に反映させるための最後のViewController.swift側の実装を見て行きます。

ViewController.swift
import UIKit
import SVProgressHUD

class ViewController: UIViewController {
    @IBOutlet weak var nameLabel: UILabel! // 名前
    @IBOutlet weak var emailLabel: UILabel! // メールアドレス
    @IBOutlet weak var introductionLabel: UILabel! // 自己紹介
    @IBOutlet weak var dateLabel: UILabel! // 取得日時

    override func viewDidLoad() {
        super.viewDidLoad()
        self.initialize()
        // プロフィール取得API
        self.executeGetUserApi()
    }

    private func initialize() {
        [nameLabel, emailLabel, introductionLabel, dateLabel].forEach {
            $0.text = ""
        }
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
}

// MARK: - Network
extension ViewController {
    private func executeGetUserApi() {
        let network = NetworkLayer()

        network.setStartHandler {()->() in
            SVProgressHUD.show(withStatus: "Loading...")
        }
        network.setErrorHandler {(error: NSError)->() in
            // 通信失敗時
            SVProgressHUD.dismiss()
            print("通信に失敗しました")
        }
        network.setFinishHandler {(result: Any?)->() in
            // 通信成功時 (※ローディングポップアップの挙動を確認するためにワザと待たせています)
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(1)) {
                SVProgressHUD.dismiss()

                guard let data = result as? UserModel else { return }
                // サーバーから取得した値を各パラメータにセットし画面へ表示
                self.nameLabel.text = data.name
                self.emailLabel.text = data.email
                self.introductionLabel.text = data.introduction
                self.dateLabel.text = data.date
            }
        }

        // パラメータセット
        let userId = 1
        // API実行
        network.requestApi(api: .profile(userId), parameters: nil, headers: nil)
    }
}

画面が表示される前にexecuteGetUserApi()で通信処理を行なっていますね。通信周りはextensionでまとめています。

最初にlet network = NetworkLayer()でインスタンスを作り、通信開始成功失敗、の3パターンをクロージャーで定義しておきます。これでNetworklayer.swift内で実行された結果によってそれぞれ適切な場所が呼ばれる感じです。

SVProgressHUDを通信開始時に表示させ、通信結果によってそれぞれの箇所でdissmiss()させていますね。こちら今回サーバー側を仮でローカルに構築し実行することになるため、非常に処理が高速でローディングのポップアップを見る暇がないため、あえてGCDでwaitさせています(実際はこんなことさせずに削除してください)。あくまでDEMO版ということで。

setFinishHandlerで最終的に各ラベルにセットし表示させていますが、こちら今Xcodeから実行してもlocalhost:8080/user/1側が動いていないため通信には失敗します。

そういうわけで、最後のサーバー側を作って行きます。  
  

Go開発環境構築

サーバーサイドといっても冒頭に書いたように、実際サーバーは立てずあくまでイメージということで、ローカルに環境を作りSwift側からはそちらを叩くようにします。

そこで今回サーバー側を実装するのに選んだのはGo言語。PHPやJavaなどをメインにやられてた方はNginXApacheなど別に入れて対応しないといけないのではと思うかもしれませんが、今回Goを選んだ理由がそこで、Goは自身でTCPポートを監視しHTTPサーバーとしての機能を持っているので、別途Webサーバーを立てることなくサーバー側を作れてしまうのです。これは利用しない手はないですね。

今回Goで実装するのにあたっての環境は以下になります。

  • Go (version go1.9.2 darwin/amd64)
  • GoLand(2017.3)  

  
Goを記述するのにエディタなど色々ありますが、自分はJetBrains製品を7年くらい愛用していて、最近このGoLandがEAP版だったGoglandから正式にGoLandと名前を変えリリースされたばかりというのもあって日々愛用しています。IDEなんで当然便利は便利なんですが如何せん有料です。一応試用期間はあるので、今回はこちらを使って説明していきます。

Goのインストール

Macでしたらこちらからパッケージをダウンロードしてきてインストールをするのが一番楽です。
自分はHomebrewからインストールしましたが、その手順も記載しておきます。

$ brew search go
$ brew install
$ go version
go version go1.9.2 darwin/amd64

GOPATHの設定

ここは環境によって変わるのですが、自分は下記のようにPATHを設定しています。

zshrc
...
# GOPATH, GOROOT
export GOPATH=$HOME/go/package:$HOME/go/workspace
export PATH=$HOME/go/package/bin:$HOME/go/workspace/bin:$PATH

GOPATHは go getでパッケージインストールする際の場所なので好きな場所を指定して問題ありません。

GoLandの設定

GoLandを起動するとプロジェクト名を決める画面が出てくるので、ここではAPISampleとします。Goをインストールした過程によって自動で設定されているとは思いますが、念のため画面上部のGoLand -> Preferences...で設定画面を開き、画像のようにGo -> GOROOT部分がちゃんと設定済みであることを確認してください。

スクリーンショット 2017-12-17 13.44.27.png
  

次に外部パッケージなどをインストールし、それをIDE上から認識させるために以下のModule GOPATHも設定してやります(自分の場合は$HOME/go/packageにしたのでそちらを指定)

スクリーンショット 2017-12-17 15.04.32.png

  

これで最低限の設定は完了なので、最後にIDE内でビルド・実行するのに簡単なプロジェクトごとの設定があるのでそちらを。
自分は既にプロジェクト作成済みなので少し見え方が違うかと思いますが、画面右上の三角の実行ボタンの隣のエリアをクリックし、Edit Configurations...を選択してください。

以下のような画面が表示されるので、左側「+ボタン」Go Buildを選択しNameにAPISampleとつけます。ここで大事なのがRun Kind:の部分でDirectoryにしておきます。これでAPISample以下のファイルが全て読み込まれ実行されることになります。

スクリーンショット 2017-12-17 14.10.34.png

サーバーサイドの実装

それでは早速実装ファイルを作っていきます。
まずはmain.goから。

main.go
package main

import (
    "github.com/julienschmidt/httprouter"

    "net/http"
    "log"
)

func main() {
    router := httprouter.New()
    router.GET("/", Index)
    router.GET("/user/:id", ShowUser)

    log.Fatal(http.ListenAndServe(":8080", router))
}

import部分で外部パッケージのhttprouterを指定しています。
Goは標準のパッケージでもJSON APIなど比較的簡単に実装出来るのが魅力ですが、このhttprouterを使えば上図のように、視覚的にも簡単にRoutingを実装できるのでオススメです。

それぞれhttp://localhost:8080/http://localhost:8080/user/iなどとリクエストを投げられた際にIndexShowUserハンドラへ処理が流れる仕組みになっています。

なおGoLand上でhttprouterを使用している箇所でエラーが出ると思うので、ターミナルを開き以下を叩いてください。

$ go get "github.com/julienschmidt/httprouter"

これで先ほど$GOPATHで指定した場所にパッケージがインストールされます。
  

次にハンドラ側の実装を行います。

handler.go
package main

import (
    "github.com/julienschmidt/httprouter"

    "encoding/json"
    "fmt"
    "time"
    "net/http"
)

func (j jsonTime) format() string {
    return j.Time.Format("2006-01-02 15:04:05")
}

func (j jsonTime) MarshalJSON() ([]byte, error) {
    return []byte(`"` + j.format() + `"`), nil
}

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    fmt.Fprintf(w, "Hello world!")
}

// http://localhost:8080/user/1 が叩かれると呼ばれる
func ShowUser(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    currentTime := time.Now()
    user := User {
        Id:           1,
        Name:         "ブタ",
        Email:       "pig@boo.com",
        Introduction: "こんにちは、ブタです。" +
                      "みなさん元気ですか? 今日も1日お仕事頑張りましょう。",
        Date: jsonTime {currentTime},
    }

    // 構造体をJSONへ変換
    data, _ := json.Marshal(user)

    defer func() {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprint(w, string(data))
    }()
}

ちなみにShowUser部分で第3引数を省略していますが、こちらpなどと定義してp.ByName("id")とすれば、リクエストにセットしたパラメータを取得出来るので、例えばユーザーの一意の情報を元にDBから該当するユーザー情報を取得、などといったことが出来ます。
ここでは特に使いませんが、イメージでidを渡すようなエンドポイントを設定しました。

こちらも細かい解説は割愛しますが、main.goでそれぞれ定義したRoutingの内容を実装した形になります。http://localhost:8080/user/:idとリクエストが投げられれば上にベタ書きしてあるjson結果を返す感じです。
  

最後にモデル側の実装を。

model.go
package main

import "time"

type jsonTime struct {
    time.Time
}

type User struct {
    Id           int       `json:"id"`
    Name         string    `json:"name"`
    Email        string    `json:"email"`
    Introduction string    `json:"introduction"`
    Date         jsonTime  `json:"date"`
}

こちらはアプリ側に返すjsonの内容を定義してます。
これで一通りサーバー側の実装も終わったので、早速右上の実行ボタンを押して起動してみましょう。

エラーも出ずに実際に起動したら、ターミナルで以下のようなcurlを叩いてちゃんと情報が返ってくるか確認してみます。

curl -vv http://localhost:8080/
*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET / HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Sun, 17 Dec 2017 06:38:56 GMT
< Content-Length: 12
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact
Hello world!%

main.goで定義してあるrouter.GET("/", Index)の内容がちゃんと返ってきていますね。

アプリとサーバー間での疎通確認

最後にXcode側で起動してるサーバーと実際に通信してみて、jsonの値がちゃんと取得出来るか試してみます。

Xcodeを立ち上げシミュレーターを立ち上げて見ましょう。(GoLand側は当然実行した状態のままで。停止させてしまうとサーバーが止まったと同義なので)

ezgif.com-video-to-gif.gif

ちゃんとローディングも表示され、サーバー(ローカルで立ち上げてるGoのプロジェクト)と連携出来てますね。
こちらをローカルではなく実際のサーバーと連携となっても、この作ったGoのプロジェクトを配置し、あとは適宜ドメインなど設定してやるだけで比較的簡単に構築も出来るので、余裕があればそちらもやってみるといいかもしれません。またSwift側の実装に関してもタイムアウト処理だったり、他にも細かい調整が実際は必要なのでその辺りも自分流にカスタマイズしていってもいいかと思います。

あと今回のサンプル(Swift/Go)、ともにGitHubにおいてありますので、試しに動かすだけという方も是非。

おわりに

ちなみに現在Swift4ではCodableというのが提供されていて、こちらを使えばSwiftyJSONは必要なかったりします... よりコード量も減らしたり出来るのですが、今回は結構昔からやってるやり方でこんな簡単にAPIクライアントの設計をしサーバー連携が出来ますよ、というのを実演してみました。

明日はフロントエンド界隈でも有名なよしこ先生(yoshiko_pg)のソースコード内検索のTipsです。

それでは良い開発ライフを〜