メドピア開発者ブログ

集合知により医療を再発明しようと邁進しているヘルステックカンパニーのエンジニアブログです。読者に有用な情報発信ができるよう心がけたいので応援のほどよろしくお願いします。

Vapor(Serverside Swift) + Nuxt.js + Cloud Run でホームページを手作りする

こんにちは。モバイルアプリを開発しています。髙橋です。 ホームページも作るのが好きなので、仕事の隙間時間で iOSDC Japan 2022 で同僚の出演を応援する意味で、 Swift のクイズサイトを作って Cloud Run で公開してみました。

メドピアは今週末いよいよ開催となる iOSDC Japan 2022 を PLATINUM SPONSOR として協賛させていただいております。

作ったホームページ

「Swift Quiz for 5.6 | メドピア」

👉 http://swiftquiz.medpeer.co.jp

正解数 6 問以上になると、正解が増えていくごとに演出が変わるので、全問正解できるまでトライしてみてください👍

  • iOSDC 向けなので、一時期 Serverside Swift に妙にハマったのもあり、せっかくなので Swift をバックエンドにしたホームページにしてみました。
  • iOSDC で配布しているメドピアのチラシには、毎年 Swift Quiz が掲載されているので「せっかくならウェブサービスにしましょう」と同僚らをそそのかしてクイズ問題をいっぱい作ってもらいました。

基礎知識

Nuxt.js とは

https://nuxtjs.org/ja/

  • JavaScript な Frontend Application Framework です。いい加減に言うと、Next.js とか jQuery みたいなやつです。
  • Nuxt 3 リリースが近いようですが、今回は Nuxt 2 で作りました。TypeScript を適宜使っていますが、解説が複雑化するので今回の Tips では JavaScript で解説しています。
  • メドピアは Vue.js 推しなので選択しました。Vue Fes 2022 にも同僚がスタッフとして参加します。

Vapor とは

https://vapor.codes

  • Swift 製の Web Application Framework です。いい加減に言うと、Express とか Rails とか Amon2 みたいなやつです。
  • Serverside Swift だと今はこれ一択という感じかもです。
  • メドピアは Rails で基本的にプロダクトを作っているので、空気を読まない感じでやってみました。

Cloud Run とは

https://cloud.google.com/run

  • Fully Managed IaaS です。いい加減に言うと、 AWS App Runner みたいなやつです。
  • 最近 GCP を個人開発だったり Android アプリの開発業務で自分的によく使っているので選択しました。
  • これもメドピアは AWS で以下略

Tips

ポイントをまとめました。

[Swift/Vapor] Vapor で Nuxt.js で SSG したサイトを配信する

Vapor で HTML ページを配信する場合、Leaf テンプレートエンジンを使って SSR するチュートリアルはよく見かけるのですが、Nuxt.js で SSG したサイトを配信する例を見たことがなかったのでご紹介です。

私は Flash を作って育ってきたので、ウェブサイトは SPA であってほしいし、HTML を表示するにあたって SSR ではない方が好きなので、Vapor の公式では紹介されていないこんな構成にしました。その理由についてつらつら語る前職時代に登壇した動画も参考になるかもしれません(この時一番太っていて黒歴史)。

セットアップ

インストールは Vapor 公式のチュートリアルをご確認ください。https://docs.vapor.codes/getting-started/hello-world/

まずは vapor new するとき、 Fluent (ORM) 使うか?Leaf (Template Engine) 使うか?の質問に Leaf の部分だけ no とします。データベースを使いたい場合は Fluent を試してみてください。

次に Public 配下にアクセスできるように、FileMiddleware を使います。configure.swift で、おそらくコメントアウトされているので戻してあげれば大丈夫です。

configure.swift
import Vapor

public func configure(_ app: Application) throws {
    app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
    try routes(app)
}

root リクエストが来たら Public フォルダの index.html を表示するように route.swift でルーティングします。

route.swift
import Vapor

func routes(_ app: Application) throws {
    app.get { req -> EventLoopFuture<View> in
        return req.view.render(app.directory.publicDirectory + "index.html")
    }
}

さらに、Scheme の設定で、ワーキングディレクトリを指定します。 Package.swift があるディレクトリを指定してあげれば大丈夫です。

ワーキングディレクトリを指定する

あとはこの Public フォルダに、Nuxt.js の generated フォルダを指定し、SSG すれば完了です。

nuxt.config.jsgenerate で vapor のプロジェクトくフォルダの Public を指定します。

nuxt.config.js
export default {
  target: 'static',
  generate: {
    dir: 'vaporproj/Public'
  }
}

Nuxt.js のソースフォルダと、Vapor の Package.swift が同階層にある場合、Xcode で Package.swift を開くと(Nuxt.js のソースコードも読み込もうとするので、特に node_modules フォルダの影響だと思うのですが)、Xcode ビルド時ハングアップしてしまうかもしれません。(自分の M1 MacBook Air では厳しい印象でした)

Nuxt.js のコードは Visual Studio Code、Vapor のコードは Xcodeで書く感じにしました。

これで nuxt generate したファイルが Vapor 経由で localhost:8080 で見られるようになると思います。

[Swift/Vapor] POST 通信を受け取って JSON を返す

ここ Serverside Swift ならでは感があっていいなと思ったポイントなんですが、Codable みたいな感じの型でデコードして、レスポンスで return するだけで、そのまま JSON レスポンスが作られました。

Content を使用します。

route.swift
import Vapor

func routes(_ app: Application) throws {
    // ... 省略
    app.post("greeting") { req -> Result in
        let message = try req.content.decode([UserMessage].self)
        // ... 普通はこの間で DB に問い合わせたりなんかやる
        return Result(message: "Hello \(message.name)")
    }
}

struct UserMessage: Content {
    let name: String
}

struct Result: Content {
    let message: String
}

Terminal から POST してみてレスポンスを確認します

% curl -X POST -H "Content-Type: application/json" -d '{"name":"medpeer"}]' http://localhost:8080/greeting

GET も JSON を返すだけなら同様です。これ Swift を普段書いている人間からすると、ちょっと気持ちいい感じがします。

[JavaScript/Nuxt.js] Syntax Highlight する

syntax highlighted

prismjs を使用しました。

https://github.com/PrismJS/prism

~/plugins/prism.js で以下を作成し

~/plugins/prism.js
import Prism from 'prismjs'
import 'prismjs/themes/prism-tomorrow.css'
import 'prismjs/plugins/toolbar/prism-toolbar'
import 'prismjs/plugins/toolbar/prism-toolbar.css'
import 'prismjs/plugins/show-language/prism-show-language'
import 'prismjs/components/prism-swift'
export default Prism

nuxt.config.js で読み込みます。

nuxt.config.js
  ...  
  plugins: [ {src: '@/plugins/prism.js' } ]
}

適当に VueComponent を作って、

~/components/HighlightedCode.vue
<template>
  <div class="prism">
    <pre class="language-swift"><code v-html="content"></code></pre>
  </div>
</template>

<script>
import Vue from 'vue'
import Prism from "@/plugins/prism"
export default Vue.extend({
  props: {
    content: {
      type: String,
      required: true,
      default: "",
    },
  },
  mounted() {
    Prism.highlightAll()
  }
})
</script>

宣言すれば OK です。

~/pages/index.vue
<HighlightedCode :content="code"></HighlightedCode>

テーマが色々あったり、コードをコピーできる機能とかオプションも色々あったり、対応している言語が本当にたくさんあって、本当至れり尽くせりですごいです。

[JavaScript/Nuxt.js] パーティクルを舞わせる

particles.vue を使用しました。tsparticlesVueComponent です。

https://github.com/matteobruni/tsparticles

~/plugins/particles.js
import Vue from 'vue'
import Particles from "particles.vue";

Vue.use(Particles)
nuxt.config.js
  ...  
  plugins: [
    { src: '@/plugins/prism.js'},
    { src: '@/plugins/particles.js' }
  ]
}

設定ファイル particles.json を作っておきます。

~/static/particles.json
{
  "autoPlay": true,
  "background": {},
  "fullScreen": {
    "enable": true,
    "zIndex": -1
  },
  "interactivity": {
    "detectsOn": "window"
  },
  "emitters": [
    {
      "position": {
        "x": 30,
        "y": -30
      },
      "rate": {
        "quantity": 5,
        "delay": 0.25
      }
    },
    {
      "position": {
        "x": 70,
        "y": -30
      },
      "rate": {
        "quantity": 5,
        "delay": 0.25
      }
    }
  ],
  "particles": {
    "move": {
      "decay": 0.01,
      "direction": "top",
      "enable": true,
      "gravity": {
        "enable": true
      },
      "outModes": {
        "top": "none",
        "default": "destroy"
      },
      "speed": {
        "min": 5,
        "max": 10
      }
    },
    "number": {
      "value": 10
    },
    "opacity": {
      "value": 1
    },
    "rotate": {
      "value": {
        "min": 0,
        "max": 360
      },
      "direction": "random",
      "animation": {
        "enable": true,
        "speed": 10
      }
    },
    "size": {
      "value": 8
    },
    "wobble": {
      "distance": 30,
      "enable": true,
      "speed": {
        "min": -10,
        "max": 10
      }
    },
    "shape": {
      "type": [
        "image",
        "image"
      ],
      "options": {
        "image": [
          {
            "src": "/images/confettie1.svg",
            "width": 30,
            "height": 27,
            "particles": {
              "size": {
                "value": 16
              }
            }
          },
          {
            "src": "/images/confettie2.svg",
            "width": 15,
            "height": 15,
            "particles": {
              "size": {
                "value": 12
              }
            }
          }
        ]
      }
    }
  }
}

あとは読み込むだけで OK です。

~/pages/index.vue
<template>
  <Particles
        id="tsparticles"
        :particlesInit="particlesInit"
        url="/particles.json"
        />
</template>

<script>
import Vue from 'vue'
import Particles from "@/plugins/particles" 
import { loadFull } from "tsparticles";

export default Vue.extend({
  methods: {
    async particlesInit(engine) {
      await loadFull(engine)
    }
  }
})
</script>

こちらも至れり尽くせり系なので、ほんとうに素晴らしいです。

[Infrastructure] Cloud Run にデプロイする

公式にも Swift / Vapor プロジェクトのデプロイの記載がなかったので、ざっと手順をまとめました。

  1. Google Cloud SDK ツール をインストール
  2. 初期化 gcloud init して Google Cloud SDK を使えるようにする
  3. プロジェクトを設定: gcloud config set project {PROJECT_ID}
  4. 確認: gcloud config list
  5. デプロイしたいディレクトリに移動 cd ${VAPOR_PROJECT}
    • Vapor の初期化をしたタイミングで Dockerfile とかができているので、これを確認して、問題なければそのまま使って OK
  6. gcloud builds submit --tag gcr.io/{PROJECT_ID}/{IMAGE_NAME} --timeout=1200s Vapor はビルドにちょっと時間かかるので --timeout=1200s または 20m をつけるのが良さげ
  7. gcloud run deploy --image gcr.io/{PROJECT_ID}/{IMAGE_NAME} --platform managed でデプロイする
  8. ロケーションを選択し(asia-northeast1 など)、Allow unauthenticated invocations = y して公開。(コンソール側で public アクセス権限を削除すればアクセスできないようにできます)

一旦これでサービスが公開されるので、ドメインの設定を行えば期待通りの表示ができるかと思います。

その他 Tips・かんそう

  • CSS 久しぶりに書くと色々進化していて素晴らしいです。
    • 上下センターも display: flex; してサイズ決めるだけなので楽ですね。
    • position: sticky; は感動しました。 
  • 画面デザインのスケッチを作るとき、 Figma コミュニティの Confettie プラグインと Code Highlighter が活躍しました。
  • Vapor で gzip も、以下の一行足すだけです。
  • FileMiddlewareCache-Control の機能を持たないので、静的なファイルは実際はやはり CDN に逃すと良さそうです。
    • この辺りは開発コミュニティ(Discord)のやりとりが参考になりました。
  • Lighthouse は、アニメーションを利用したり HIG に合わせた配色などにしたため、 ALL 100 点とはいきませんでしたが、 service-worker を使い(単にオフライン表示しか対応していませんが) PWA 対応バッジもついたので、フロントエンド側の開発も満足です。

おわりに

某トークン用→ #Healthtech