😺

30分でフルスタックWEBアプリをデプロイする「N-Dev」と、開発に手得た知見

2024/03/24に公開

長いですが「N-DEV」については以下の動画をざざっとみて頂いたらわかると思われます。
https://youtu.be/TxphFXMI-3w

はじめに

DB設計、マイグレーション、コード・API自動生成等々が可能な統合アプリ管理システム「N-DEV」の開発に取り掛かってから早5カ月、途中色々忙しくもありかかりっきりではなかったものの、時が経つのは早いものです。
この「N-DEV」もようやく自動生成までできるようになり、まあ使ったほうが便利かな、というレベルまで来ました。
納得いく最終想定からすればまだ7割といったところではありますが。

そんなことは置いておいて、
今回の「Nuxt3 × Node × TypeScript × Vercel × Firebase」という弊社初の構成でのそこそこ大規模な開発の中で、得た知見を今回まとめてみようと思います。
既に記事にしたものもかなりありますので、そちらは記事へのリンクで詳細は省かせていただきます。

ログインが必要ですが、今なら無料です。
「N-DEV」https://n-dev.nexsjp.com/
恐らくMac等からもアクセスできるようになったと思います。GitHubログインもできます。
構成が少し特殊(というほどでもないですが、もはやReact以外は特殊な風潮)ですが、もしよろしければ使ってみてください。
Nuxt3導入にもそこそこ良いかと思います。コードはだいぶん適当でお恥ずかしいですが。
*現在使ってみた動画、簡単な操作説明を準備中です。

クラスからプロパティを抜き出す関数を作った

Nuxt3とは関係ありませんが、TypeScriptに変えてクラスを使い始めたことから、プロパティを一覧にして使いたいこともあり、記事を書きました。
https://zenn.dev/tanukikyo/articles/cc1d5762252867

Vue3でTSのクラスを扱う場合のリアクティビティの検証

この記事は私の書く記事の中ではまともに検証等も行いまして、結構有用ではないかな、と思われる記事になります。
私の記事の中では圧倒的な5,300回を超える閲覧を頂いております。
Vue3をご利用の方は、是非一度ご覧ください。
内容としては、Vue3でTypeScriptのクラスを使用する場合、クラスフィールドやクラスインスタンスをリアクティブにする方法としてのベストプラクティスはどのようになるか、を挙動の検証と共に検討したものです。
https://zenn.dev/tanukikyo/articles/40603fbdc88c05

Nuxt3のuseFetchの出番

Vue3、Nuxt3時代は"axios"ではなく"Fetch API"を使用するようです。
一方で、Nuxt3の公式の勧める"useFetch"は生の"Fetch API"とはだいぶ違うもので、"axios"のつもりで使用すると思った挙動にはなりません。
そのあたりを以下の記事にまとめました。
https://zenn.dev/tanukikyo/articles/96a79ce04d7191

Nuxt3のPWA化

本項は、本当は「N-DEV」開発ではなく、開発中に依頼を頂いてNuxt3を使用して作成したアプリで得た知見ですが、正にNuxt3の課題ですので示しておきます。
言わずと知れたPWAにNuxt3で作成したアプリを対応させる話です。
https://zenn.dev/tanukikyo/articles/a8dda0432bd777

SQL文のパースとステートメントのパース

「N-DEV」の中心にはDB設計があり、SQLの操作が核といっても過言ではないと思います。
そのSQLの操作についてまとめた記事になります。
https://zenn.dev/tanukikyo/articles/fdc7ef97b9e7b0

Null合体演算子

開発中にNull合体演算子「??」の挙動が三項演算子「?」と同じような挙動と勘違いし、結構悩んだため記事にしたものです。ちなみに、「?」っぽい挙動のするのは「||」です。
https://zenn.dev/tanukikyo/articles/e4dbf70a860821

2024年でもOAuthログインしたい

「N-DEV」はOAuthログインはGoogleログインとGitHubログインに対応しておりますが、これまでのiOS16.1以降のみならず、Googleもリダイレクトログインを拒否するようになるとのことで、Firebase Authenticationを利用したOAuthのダイレクトログインをVercelで適切に行う方法を有料ですが記事にしました。
https://zenn.dev/tanukikyo/books/21e9996cbeeb5b

2024年2月のメール送信

我々を苦しめるのはリダイレクトログインのみではない。
GoogleやYahooのメールの新要件も苦しめてくる。
以下の記事では"SendGrid"を使用してNodeからメールを送信する方法とその注意点についてまとめました。
https://zenn.dev/tanukikyo/books/21e9996cbeeb5b

IDEをアップデートしよう

Nuxt3はまだ新しいフレームワークであり、Volarの状況の変化もあって、WebStormでは適切に扱えませんでした。vueファイルをうまく使うために、β版ではあるけれど2024年版のWebStormを使用しよう、という記事です。
https://zenn.dev/tanukikyo/articles/05eb496ff233af

SQLのシンタックスハイライト


「N-DEV」をより使いやすくするために、SQL(特にMySQL)のシンタックスハイライトに挑戦しました。
今回使用したハイライターは古参の「Prism.js」です。古参過ぎて、使用方法はCDNを使用して導入しているようなものばかりで、Nuxt3で使用するのは少々骨が折れました。
現代なら「highlight.js」の方が良いかもしれません(使ってませんが)。
「Google Code-Prettify」というものもあるようですが、開発が終了してますので、これから使用するのはあまりお勧めできません。

以下に使用方法をまとめたいと思います。

インストール

以下のコマンドでインストールします。yarnの方など、それぞれの環境に合わせてインストールしてください。

npm i prismjs

TypeScriptご利用の方はtypesもインストールしてください。

npm i -D @types/prismjs

pluginsの設定

恒例ですが、pluginsを設定します。Nuxt3の場合は、例えば以下のような形式。
テーマと言語を選択してあげます。
今回はダークテーマで、SQLのハイライトのみ使用しました。

prism.client.ts
import Prism from 'prismjs'

// テーマの読み込み
// import 'prismjs/themes/prism.css'; // デフォルトのテーマ
import 'prismjs/themes/prism-dark.min.css';

// 言語の読み込み
import 'prismjs/components/prism-sql.min.js';

export default defineNuxtPlugin((nuxtApp) => {
    Prism
});

使用方法

TS側では"Prism"をインポートして以下のように使用します。
今回は合わせてSQLをフォーマットする"sql-formatter"を使用しています。

import {format} from 'sql-formatter';
import Prism from 'prismjs';

let sql = format(_sql, {language: "mysql"});
Prism.highlightAll();

Template側は以下のようにしました。成形したsqlの状態を維持して出力するため、PREタグ(<pre>)にCODEタグ(<code>)を内包しました。恐らくもっと賢い方法がある気はします。

<pre><code class="language-sql" style="white-space: pre-wrap;">{{ sql }}</code></pre>

「highlightAll」関数で、全CODEタグ(<code>)の内容を、与えられたクラスに応じてハイライト(ハイライティング?)します。
今回は「language-sql」クラスを与えており、SQLとしてのハイライトに設定してます。
基本的にはこれだけです。ただし、上のコードではsql変数が更新されるごとに「Prism.highlightAll();」を呼んでハイライティングする必要がありますので気を付けてください。
対象を選んで実行したい場合は"highlightElement"などを使用することになるかと思います。

参考:
https://prismjs.com/docs/Prism.html#.highlightAll
https://frontworks.dev/articles/prism-js/

ファイル(ソースコード)をWEBでダウンロードさせる

ファイル構造を表示して、ファイルをクリックするとそのファイルをダウンロード、フォルダをクリックすると、フォルダ以下をZIPにまとめてダウンロードするUIを作成しました。

ダウンロードさせるデータの用意

ダウンロードさせるデータはテキストで用意したり色々ですが、編集の必要が無いものはファイルとして「public」フォルダに入れることにしました。public以外に入れるとリネームされたりミニファイされたりして大変と思います。
ただし、public下であっても、「index.d.ts」はデプロイ時に無くなってました。タイプファイルは多分勝手に不必要とみなされるものだと思われます。素直に「types.ts」とかにすれば大丈夫です。
あと、ピリオド「.」で始まるファイルは単体でダウンロードする際に「.txt」に書き換えられたりしました。例えば「.gitignore」は「gitignore.txt」に変更をお勧めされました。

ダウンロード方法

上記public下(public/source下)のファイルの場合、中身がテキストなら以下のようにfetchでテキストとして取得してからダウンロードしました。

const txt = await fetch(`/source/${fileName}`).then((res) => res.text());

画像の場合、以下のようにBlobで取得してダウンロードしました。

const blob = await fetch(`/source/${fileName}`).then((res) => res.blob());

ZIPにまとめる作業は「JSZip」というライブラリを使用しました。JSZipは再帰的な処理でも使いやすいように設計されているのか、今回のようにフォルダの中にフォルダがあるような、再帰的な処理でもシンプルに実装できました。

ファイルのダウンロードには「file-saver」を使用しました。JSZipがBlobで作ってくれるので、それをダウンロードさせるだけで結構楽です。

「ウィルスを検知しました」

自動生成したコードをダウンロードさせようとすると、「ウィルスを検知しました」と出てダウンロードに失敗することがあります。
これは、例えば「Google Chrome」を使用していれば当然Google Chromeから言われますが、実際にはPCのセキュリティソフトによって弾かれた場合などに表示されます。
本システムでは、実行可能なコードを出力する為、悪意のあるソースコードではありませんが、どうしても弾かれる場合があります。
ファイルの中では、特にAPIファイルが弾かれる場合が多いように思います。
当初、改行等で弾かれないように工夫したりしましたが、そういった対策は一時的で、2回目ははじかれるようになったりしたため、現状はセキュリティソフトの設定の変更等で対応をお願いしています。

おまけ、作成したUIコンポーネント

コンポーネント

FileTreeView.vue
<script lang="ts">
export default defineComponent({
  layout: false,
  name: "FileTreeView",
});
</script>
<script setup lang="ts">
import JSZip from "jszip";
import {saveAs} from "file-saver";

const Props = withDefaults(defineProps<{
  items: TreeItem,
  name: string,
}>(), {
  name: "empty",
});

const itemClicked = async () => {
  let item = Props.items;
  switch (item.type) {
    case "dir":
      let zip = new JSZip();
      let folder = zip.folder(Props.name);
      await downloadDir(folder, item);
      zip.generateAsync({type: 'blob'}).then(function (blob) {
        saveAs(blob, "N-DEV_template.zip");
      });
      break;
    case "file":
      switch (item.action) {
        case "get":
          const txt = await fetch(`/source/${Props.name}`).then((res) => res.text());
          downloadTextFile(txt, Props.name);
          break;
        case "file":
          const blob = await fetch(`/${Props.name}`).then((res) => res.blob());
          downloadBlobFile(blob, Props.name);
          break;
        case "create":
          if (!item.fileContent) break;
          downloadTextFile(item.fileContent, Props.name)
          break;
      }
      break;
  }
};

const downloadDir = async (folder: JSZip | null, items: TreeItem) => {
  if (!items || !folder) return;
  for (let name in items.children) {
    let item = items.children[name];
    switch (item.type) {
      case "dir":
        await downloadDir(folder.folder(name), item);
        break;
      case "file":
        switch (item.action) {
          case "get":
            const txt = await fetch(`/source/${name}`).then((res) => res.text());
            folder.file(name, txt);
            break;
          case "file":
            const blob = await fetch(`/${name}`).then((res) => res.blob());
            folder.file(name, blob);
            break;
          case "create":
            if (!item.fileContent) break;
            folder.file(name, item.fileContent);
            break;
        }
        break;
    }
  }
};

const downloadTextFile = (content: string, fileName: string) => {
    let blob: Blob = new Blob([content], {type: "text/plain"});
    downloadBlobFile(blob, fileName);
}

const downloadBlobFile = (blob: Blob, fileName: string) => {
    let url: string = window.URL.createObjectURL(blob);
    let a: HTMLAnchorElement = document.createElement("a");
    a.href = url;
    a.download = fileName;
    a.click();
}
</script>

<template>
  <div class="border rounded d-inline-block my-1">
    <div class="d-flex align-start pr-1">
      <div style="width: 200px; text-overflow: ellipsis; overflow-x: auto;" class="pointer pa-1 hover" @click="itemClicked">
        <span class="mr-1 text-gray">-</span>
        <v-icon v-if="items.type==='dir'" color="gray" class="mr-2">mdi-folder</v-icon>
        <v-icon v-else-if="items.type==='file'" color="gray" class="mr-2">mdi-file</v-icon>
        <span class="mr-2">{{ name }}</span>
        <span v-if="items.type==='dir'" class=" text-gray">/</span>
      </div>
      <div v-if="items.children && Object.keys(items.children).length !== 0">
        <div v-for="(item, name) in items.children">
          <file-tree-view :items="item" :name="name + ''"/>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped lang="scss">

.hover:hover {
  background: rgb(220, 220, 220);
}
</style>
index.vue
<script setup lang="ts">
interface TreeChildren {
    [index: string]: TreeItem,
}

interface TreeItem {
    type: "dir" | "file",
    action?: "get" | "file" | "create",
    fileType?: "api" | "page" | "class",
    children?: TreeChildren,
    fileContent?: string,
}

let projectDirectory: Ref<TreeItem> = ref({
  type: "dir",
  children: {},
});
let pd: Dictionary = {
  components: {
    type: "dir",
    children: {
      l: {
        type: "dir",
        children: {
          "AppBar.vue": {
            type: "file",
            action: "get"
          },
          "Footer.vue": {
            type: "file",
            action: "get"
          },
        }
      },
    }
  },
  utils: {
    type: "dir",
    children: {
      "type.ts": {
        type: "file",
        action: "get"
      },
      "myUtils.ts": {
        type: "file",
        action: "get"
      },
    }
  },
  "package.json": {
    type: "file",
    action: "get"
  },
  "README.md": {
    type: "file",
    action: "get"
  },
  ".gitignore": {
    type: "file",
    action: "get"
  },
};
projectDirectory.value = {
  type: "dir",
  children: pd,
};
</script>
<template>
<file-tree-view :items="projectDirectory" :name="appName"/>
</template>

Discussion