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はコンピュータ通信プロトコル
https://ja.wikipedia.org/wiki/WebSocket
HTTP とは異なり、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の通信プロトコルを表します。
また、http
がhttps
になるのと同様に、セキュア通信の場合はws
がwss
になります。
右側の「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のサーバから作成します。
新規プロジェクトでも同じものでも構いません!
全体の説明
今回のチャットアプリは簡易的なもので、本筋と関係のない部分はサラッと説明したり、端折ったりします。
アプリの流れとしては、以下のとおりです。
- クライアントとWebSocketサーバ(以下サーバ)を接続する
- サーバからクライアントに、最初のメッセージを送る
- クライアントからサーバに質問文を送る
- サーバが受信した質問をChatGPT(!!)に送る
- ChatGPTから受信した回答をクライアントに送る
- クライアントはサーバから受信した回答を表示する
もしかしたら、ChatGPTは有料アカウントが必要かもしれませんが、そこはご容赦ください…
また、あくまでサンプルなので、エラー制御のアンラップなどは雑です。
仕事で行う場合は、丁寧にやりましょう。
目次
- 事前準備(さらっと)
- Data Modelの作成(さらっと)
- Clientの作成(さらっと)
- Controllerの作成
- Routerの作成
- クライアントの作成(さらっと)
- 動作確認
- まとめ
0. 事前準備(さらっと)
- 用意するもの
- ChatGPTのAPI Key
- このページの「Add your API key」から作れます。
- やる気
- 根気
- 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.swift
のfunc 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)!)
}
Swift Concurrency(await)
を使うためにTask {}
を記述- 接続確立後にすぐ送ってしまうと、クライアントでの固定メッセージっぽかったので、2秒間のsleepを追加
- サーバから送るメッセージを
SystemMessage
のData Modal
で作成 - 3のJson化
- 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)
}
}
解説をすると
- サンプルと同様にメッセージを受信する機構を記述
- ブロック内でawaitを使用するので、Taskを記述
- メッセージをJson形式のString型で受信するため、Controllerで一旦Data型に変換し、UserMessageとしてデコード
- Controller経由でChatGPTに送信→受信
- String形式で送信したいので、Data ModelをJsonのData型にエンコード
- 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とか)、以下の回避策を実施して下さい。
回避策
- WebSocketサーバのプロジェクトを開く
- Xcode上部のTargetからEdit Scheme…を選択
- 左PaneのRun>画面上部のArgumentsを選択
- Arguments Passed On Launchに
serve --hostname <ローカルIPアドレス>--port 8080
を入力し、チェックが入っていることを確認 - 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. 動作確認
クライアントアプリも出来上がりましたので、動作確認をしてみましょう。
- Vapor WebSocketサーバを起動する
- チャットアプリを起動する
- メッセージのやり取りを楽しむ
7. まとめ
今回はVaporでWebSocketサーバを作成しました。
単純にサーバサイド開発ではなく、クライアントを作成する際のスタブサーバを作成するのにもシンプルに作れるかと思います。
それでは、素敵なSwiftライフを!!