Hello, Swift ラバーなみなさん。これからラバーのみなさん。


私は以前にこんな記事を書きました。
ServerSide-Swiftの雄 「Vapor」で簡易APIサーバを作ってみた

あれから半年。
お仕事でWebSocketに触る機会があったので、サーバ側もSwiftで作れるんじゃね?と思って、今回の記事を書いてみました。 Swiftを愛し、Swiftに愛された皆さんにはわかっていただけると思います!
さっそく見ていきましょう!!

Vaporとは

基本的な部分は、
前回の記事を確認いただきたいのですが、ざっくりと説明すると、
Swift+Xcodeで構築するWeb Frameworkです。
WebアプリからRestAPIサーバに、今回のWebSocketサーバもSwiftで作成することが出来ます。

導入

今回のバージョンはこんな感じです。
・MacOSX: 13.4.1
・Xcode: 14.3.1
・Homebrew: 4.0.26

前回と基本的には同じ流れですが、もしXcodeのバージョンが14.3(Siwft 5.8)以降の場合、Vaporのアップデートが必要になります。 こんなエラーが出ます。

error: 'vaporwebsocketsample': package 'vaporwebsocketsample' is using Swift tools version 5.8.0 but the installed version is 5.7.1 

私はXcode 14.3.1が入っていたので、以下の手順でアップデートを行います。

$ brew upgrade vapor 
...
...
$ vapor --version
framework: 4.77.0

toolbox: 18.7.1 の導入がまだの方は前回の記事を参考にして下さい!

プロジェクト作成

基本的に作成の流れは一緒です。
プロジェクトを作成したいディレクトリに移動し、

$ vapor new <プロジェクト名> 

今回は「VaporWebSocketSample」にしましたので、下記のようになります。

$ vapor new VaporWebSocketSample

上記コマンド入力後にいろいろ質問されますが、今回はすべて「n」でOKです。
(プロジェクト作成時にまとめて指定も出来ます。) プロジェクトディレクトリに移動します。

$ cd VaporWebSocketSample

動かす

前回は

$ vapor xcode

でプロジェクトが開始できましたが、今回使用するバージョンは仕様が変わっており、

$ open Package.swift

でXcodeを開きます。
端末をMy Macに設定し、⌘+Rで動かしてみましょう!
そして、疎通確認です。
任意のブラウザのURLに以下を入力します。
http://localhost:8080/hello 以下の画像ができていれば、無事に疎通OKです!

実装していく

では、WebSocketを実装していきます。
ところでWebSocketをご存知でしょうか?
以下、Wikipediaから引用した一部を抜粋したものです。

WebSocketはコンピュータ通信プロトコル
HTTP とは異なり、WebSocket は全二重通信を提供します。

https://ja.wikipedia.org/wiki/WebSocket

要は、RestAPIなどと違い、双方向通信ができるということです(雑)!
具体的によく使われているのがチャットアプリなど、リアルタイムメッセージ系がわかりやすいかもしれません。
さぁ、雑に説明したところで、実装して動かしてみましょう。


プロジェクトのSource/App/routes.swiftを開いて下さい。
前回だと、RestAPIやブラウザ用のルーティングを用意しました。
今回はWebSocketなので、

func routes(_ app: Application) {}

のメソッド内に

app.webSocket("foo") { req, ws in
    print("WebSocket connected")
}

と入れてみます。

動かしてみましょう。

今回使うのはGoogle Chrome拡張のWebSocket Test Clientです。

似たような拡張機能は他にもあると思いますので、任意のものでOKです。
今回は上記のものでデバッグしていきます。 ⌘+Rで起動し、
先程の拡張機能のURLに以下を打ち込みます。
ws://localhost:8080/foo

…お気づきでしょうか…??
先程の疎通確認では、URLのプロトコル部分がhttpになっていたのに対し、wsになっているではありませんか。
そう、これがWebSocketの通信プロトコルを表します。
また、httphttpsになるのと同様に、セキュア通信の場合はwswssになります。

右側の「Open」を押してみましょう!
Status: OPENEDになって、Xcodeのログに「WebSocket connected」と表示されましたでしょうか??
表示されましたら、無事にWebSocketの疎通ができました!


ただ、WebSocketはこれからが大事なところです。 まずは受信です。
先程追加した、webSocketクロージャの中に以下を埋め込んでみます。

while (true) {
    sleep(2)
    ws.send("Hello")
}


そして、XcodeをRunし、TestツールからOpenしてみましょう。
2秒毎に「Hello」とMessageを受信します。
このように、接続が確立している状態なら、自由に情報のやり取りができるのが、WebSocketの利点です。

今度は受信もしてみましょう。
いったん、while文はコメントアウトをし、以下を埋め込みます。

ws.onText { ws, text in
    print(text)
} 

そして同じようにRunとOpen を行い、
Requestの入力欄に「foooo」と入れてみまして、Sendを押します。
Xcodeのログに「foooo」と表示されましたでしょうか?
これでなんとなく、チャットアプリが作れるような気がしてきたんじゃないですか?

では、さっそく作りましょう!

チャットアプリを作る

まずはVaporのサーバから作成します。
新規プロジェクトでも同じものでも構いません!

全体の説明

今回のチャットアプリは簡易的なもので、本筋と関係のない部分はサラッと説明したり、端折ったりします。
アプリの流れとしては、以下のとおりです。

  1. クライアントとWebSocketサーバ(以下サーバ)を接続する
  2. サーバからクライアントに、最初のメッセージを送る
  3. クライアントからサーバに質問文を送る
  4. サーバが受信した質問をChatGPT(!!)に送る
  5. ChatGPTから受信した回答をクライアントに送る
  6. クライアントはサーバから受信した回答を表示する

もしかしたら、ChatGPTは有料アカウントが必要かもしれませんが、そこはご容赦ください…
また、あくまでサンプルなので、エラー制御のアンラップなどは雑です。
仕事で行う場合は、丁寧にやりましょう。

目次

  1. 事前準備(さらっと)
  2. Data Modelの作成(さらっと)
  3. Clientの作成(さらっと)
  4. Controllerの作成
  5. Routerの作成
  6. クライアントの作成(さらっと)
  7. 動作確認
  8. まとめ

0. 事前準備(さらっと)

  • 用意するもの
    • ChatGPTのAPI Key
    • やる気
    • 根気

1. Data Modelの作成(さらっと、端折る)

要は後にJsonに変換するデータの塊です。

ここで作成するもの
  • ChatGPTとの通信用のData Model
  • クライアントとの通信用のData Model
ChatGPTとの通信用のData Model

リクエスト

struct ChatGptRequest: Codable {
    var model: String
    var messages: [Message]

    struct Message: Codable {
        var role: String
        var content: String
    }
}

extension ChatGptRequest {
    static func create(text: String) -> Self {
        let message = ChatGptRequest.Message(role: "user", content: text)
        let request = ChatGptRequest(model: "gpt-3.5-turbo", messages: [message])
        return request
    }
}

レスポンス

struct ChatGptResponse: Codable {
    var id: String
    var object: String
    var created: Int
    var model: String
    var choices: [Choice]
    var usage: Usage

    struct Choice: Codable {
        var index: Int
        var message: ChatGptRequest.Message
        var finishReason: String

        enum CodingKeys: String, CodingKey {
            case index
            case message
            case finishReason = "finish_reason"
        }
    }

    struct Usage: Codable {
        var promptTokens: Int
        var completionTokens: Int
        var totalTokens: Int

        enum CodingKeys: String, CodingKey {
            case promptTokens = "prompt_tokens"
            case completionTokens = "completion_tokens"
            case totalTokens = "total_tokens"
        }
    }
}
クライアントとの通信用のData Model

SystemMessage(サーバからクライアントに送るメッセージ)

import Vapor

struct SystemMessage: Content {
    var message: String
}

extension SystemMessage {
    static func create(from gpt: ChatGptResponse) -> Self {
        guard let message = gpt.choices.first?.message.content else { fatalError() }
        return .init(message: message)
    }
}

UserMessage(クライアントからサーバに送られてくるメッセージ)

import Vapor

struct UserMessage: Content {
    var message: String
}

2. Clientの作成(さらっと)

ここでは、ChatGPTとのRestAPIやり取り用のClientを作成します。
ですが、本筋と関係ないので任意のライブラリでサクッと作っても問題ありません。

Client

ちなみにSwift Concurrencyを使っています。

actor RestApiClient {
    func request(urlRequest: URLRequest) async throws -> Data {
        async let (data, _) = URLSession.shared.data(for: urlRequest)
        return try await data
    }
}
リクエスト

ここも本筋と関係ないので好きなように作って下さい。

struct ChatGptRequestParam: RestParamProtocol {
    typealias Request = ChatGptRequest
    typealias Response = ChatGptResponse

    var text: String

    var baseUrl: String {
        return "https://api.openai.com"
    }

    var path: String {
        return "/v1/chat/completions"
    }

    var method: HttpMethod {
        return .post
    }

    var headers: [String : String]? {
        return [
            "Authorization": "Bearer \(CHAT_GPT_KEY)",
            "Content-Type": "application/json",
        ]
    }

    var params: ChatGptRequest? {
        return .create(text: text)
    }

    var queryItems: [URLQueryItem]? = nil

    var body: Data? = nil
}

3. Controllerの作成

大事なところが来ました。
Controllerです。MVCでいうとCです。

とはいえ、今回のアプリだと単純にChatGPTとの通信の仲介くらいしかやっていません。
チャットアプリを例にフルサイズで考えると、画像/音声の処理や、DBとの仲介などが考えられます。

actor Controller {
    private let client = RestApiClient()

    func onText(_ text: String) async -> String {
        guard let data = text.data(using: .utf8),
              let decode = try? JSONDecoder().decode(UserMessage.self, from: data)
        else { fatalError() }

        let systemMessage = try! await request(userMessage: decode)
        let encoded = try! JSONEncoder().encode(systemMessage)
        return String(data: encoded, encoding: .utf8)!
    }

    private func request(userMessage: UserMessage) async throws -> SystemMessage {
        let param = ChatGptRequestParam(text: userMessage.message)
        let request = try param.buildUrlRequest() // URLRequestを生成するもの、コードは貼り付けてないです
        let response = try await client.request(urlRequest: request)
        let decoded = try param.response(from: response)
        return .create(from: decoded)
    }
}

4. Routerの作成

そしてrouterの作成です。
先程のサンプルと同様に
Sources/App/routes.swiftfunc routes(_ app: Application) {}内に記述します。

機能としては、以下になります。

  • WebSocket通信の確立
  • メッセージの送信
  • メッセージの受信
WebSocket通信の確立

今回はpathはchatにしました。
任意のもので大丈夫です。
app.webSocket("chat") { req, ws in }

メッセージの送信

通信確立後に、スタートメッセージを送りたいので、以下を記述します。

Task {
    try! await Task.sleep(nanoseconds: UInt64(2 * 1_000_000_000)) 

    let m = SystemMessage(message: "Hi! ChatGPTに質問を送るよ!なにか質問文を送ってね!")
    let encoded = try JSONEncoder().encode(m)
    try! await ws.send(String(data: encoded, encoding: .utf8)!)
}
  1. Swift Concurrency(await)を使うためにTask {}を記述
  2. 接続確立後にすぐ送ってしまうと、クライアントでの固定メッセージっぽかったので、2秒間のsleepを追加
  3. サーバから送るメッセージをSystemMessageData Modalで作成
  4. 3のJson化
  5. 4をString形式にして送る

ws.send()について、
今回はString型で送るようにしていますが、Data型(binary)で送ることも可能です。
クライアントとの調整も必要なので、どちらがいいかはシステム間で調整して下さい。

メッセージの受信→APIリクエスト

まずはコード

ws.onText { ws, text in
    Task {
        let responseMessage = await controller.onText(text)
        try! await ws.send(responseMessage)
    }
}

解説をすると

  1. サンプルと同様にメッセージを受信する機構を記述
  2. ブロック内でawaitを使用するので、Taskを記述
  3. メッセージをJson形式のString型で受信するため、Controllerで一旦Data型に変換し、UserMessageとしてデコード
  4. Controller経由でChatGPTに送信→受信
  5. String形式で送信したいので、Data ModelをJsonのData型にエンコード
  6. 5をString型に変換し、送信する

シンプルですね〜
簡単なチャットアプリならこんだけで作れちゃいます。

5. クライアントの作成(さらっと)

チャットアプリのソースコードですが、
SwiftUIの説明など、Vaporの本筋と関わりがない部分の説明やコードは省略しています。

サーバと関わる部分は以下

WebSocketクライアント

今回はStarScreamというライブラリを使用しています。
もちろん任意のもので問題ありません。

import Starscream

final class WsClient {
    static let shared = WsClient()

    private var messageHandler: ((Data) -> Void)?
    var message: AsyncStream<Data> {
        return .init { continuation in
            self.messageHandler = { value in
                continuation.yield(value)
            }
        }
    }

    private var socket: WebSocket!

    func open() async throws {
        guard let url = URL(string: "ws://<ローカルIPアドレス>:8080/chat") else {
            fatalError()
        }

        let request: URLRequest = {
            var request = URLRequest(url: url)
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            return request
        }()

        socket = WebSocket(request: request)
        socket.delegate = self
        socket.connect()
    }

    func close() {
        socket.disconnect()
        socket = nil
    }

    func request(param: Data) {
        guard socket != nil else { return }
        let s = String(data: param, encoding: .utf8)!
        socket.write(string: s)
    }
}

extension WsClient: WebSocketDelegate {
    func didReceive(event: WebSocketEvent, client: WebSocket) {
        switch event {
        case .text(let string):
            guard let data = string.data(using: .utf8) else { return }
            messageHandler?(data)

        default: break
        }
    }
}

IPアドレスはMacのローカルIPアドレスを指定して下さい*1(下に注釈あり)。

また、WebSocketの受信は非同期かつ、いつ来るかわからないものなので、リアクティブなもので処理をするのがおすすめです。
今回はSwift ConcurrencyのAsyncStreamを使用していますが、RxSwift / Combineなどでやるのもいいと思います。


*1

ここで大事なポイントがあります。
先程のブラウザからのテストでは、localhostで接続をしていましたが、Xcode Simulatorの場合、localhostでは接続が出来ません。
また、こういう場合、大抵はローカルIPアドレスを指定し、接続するのですが、Vaporの仕様上、素直に接続が出来ません。
なにかサーバを立てるか(MAMPとか)、以下の回避策を実施して下さい。

回避策
  1. WebSocketサーバのプロジェクトを開く
  2. Xcode上部のTargetからEdit Scheme…を選択
  3. 左PaneのRun>画面上部のArgumentsを選択
  4. Arguments Passed On Launchにserve --hostname <ローカルIPアドレス>--port 8080を入力し、チェックが入っていることを確認
  5. closeし、起動

ローカルIPアドレスは、
Macの設定 > ネットワークを開き、接続しているネットワークを選択(今回はWi-Fi)、Mac OSのバージョンにもよりますが、Ventura以降だと、接続しているWi-Fiの詳細ボタン > TCP/IP > IPアドレスで確認できます。
ここに関しては、ググったほうがわかりやすいです。

ViewModel

まずはソースコード

final class ViewModel: ObservableObject {
    private let client = WsClient()
    private var task: Task<Void, Never>?

    @Published var messages: [Message] = []

    init() {
        setup()
    }

    deinit {
        task?.cancel()
        client.close()
    }

    private func setup() {

        task = Task {
            guard task?.isCancelled == false else { return }

            try! await client.open()

            for await data in client.message {
                let decoded = try! JSONDecoder().decode(SystemMessage.self, from: data)
                let message = Message.create(from: decoded)
                
                Task.detached { @MainActor in
                    self.messages.append(message)
                }
            }
        }
    }

    func sendMessage(text: String) {

        messages.append(.init(actor: .user, text: text))

        let param = UserMessage.create(text)
        let data = try! JSONEncoder().encode(param)
        client.request(param: data)
    }
}

特別説明することもないのですが、
今回はSwiftUIなので、メッセージを受信するたびに@Publishedをつけた変数にappendをしていき、View側でリアクティブに更新しています。

その他

本筋とは違いますが、まったくサンプルがないのもやりづらいと思うので、メインどころを以下に置きます。

View

import SwiftUI

struct ContentView: View {

    @StateObject private var viewModel: ViewModel = .init()

    @State private var text: String = ""

    var body: some View {
        VStack {
            ScrollViewReader { proxy in
                ScrollView(.vertical, showsIndicators: false) {
                    LazyVStack(spacing: 20) {

                        Spacer()
                            .frame(height: 20)

                        ForEach(viewModel.messages) { message in
                            MessageView(message: message)
                                .padding(.horizontal, 30)
                        }
                    }
                }
                .onChange(of: viewModel.messages.count) { _ in
                    withAnimation(.easeInOut) {
                        if let id = viewModel.messages.last?.id {
                            proxy.scrollTo(id, anchor: .bottom)
                        }
                    }
                }
            }

            TextArea()
        }
        .background(Color.background)
    }

    @ViewBuilder
    func MessageView(message: Message) -> some View {
        HStack {
            if message.actor == .user {
                Spacer()
                    .frame(minWidth: UIScreen.main.bounds.width * 0.2)
            }

            Text(message.text)
                .font(.body)
                .foregroundColor(message.actor == .user ? .white : .black)
                .padding(20)
                .background(
                    message.actor == .user
                    ? RoundedCorners(type: .user)
                    : RoundedCorners(type: .system)
                )
                .layoutPriority(1)

            if (message.actor == .system) {
                Spacer()
                    .frame(minWidth: UIScreen.main.bounds.width * 0.2)
            }
        }
    }

    @ViewBuilder
    func TextArea() -> some View {
        HStack {
            TextField("質問を入力してください", text: $text)
                .frame(height: 36)
                .padding(4)
                .background(.white)
                .cornerRadius(4)

            Button {
                sendMessage()
            } label: {
                Image(systemName: "paperplane.fill")
                    .frame(width: 44, height: 44)
                    .foregroundColor(Color.background)
                    .background(Color.userMessageBackground)
                    .cornerRadius(4)
            }
        }
        .padding(.horizontal, 30)
    }

    private func sendMessage() {
        if text.isEmpty { return }
        viewModel.sendMessage(text: text)
        text = ""
    }
}


Message

enum ActorType {
    case user, system
}

struct Message: Identifiable, Equatable {
    var id: UUID = .init()
    var actor: ActorType
    var text: String
}

extension Message {
    static func create(from systemMessage: SystemMessage) -> Self {
        return .init(
            actor: .system,
            text: systemMessage.message
        )
    }
}

6. 動作確認

クライアントアプリも出来上がりましたので、動作確認をしてみましょう。

  1. Vapor WebSocketサーバを起動する
  2. チャットアプリを起動する
  3. メッセージのやり取りを楽しむ

7. まとめ

今回はVaporでWebSocketサーバを作成しました。
単純にサーバサイド開発ではなく、クライアントを作成する際のスタブサーバを作成するのにもシンプルに作れるかと思います。

それでは、素敵なSwiftライフを!!



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる