週間アラーム(早速のTauriの習作アプリケーション)

今更の話だけど、コロナ禍でリモートワークなど自分も含めて自宅で活動することが多くなり、自宅などでも適切なタイムスケジュールでメリハリを付ける必要が増えてきたと思います。

そのためには会社や学校と同じように指定した時間割でアラーム(チャイム)を鳴らす設備があると便利かと思い、既存のアプリケーションを探して使用しておりました(スマホ/タブレット用アプリ)。

使用したアプリはスケジューリングした指定時刻に好きな音源を鳴らすことが出来て便利だったが、カレンダーとの関連が無く、日祝日など休みの日にもチャイムが鳴ってしまうのでその度にOFFにするのが面倒でした。

ならば作ってみようネタにしようということで、構想から(・・・というのが10月くらいのお話)。

  1. 平日<休日<曜日個別<祝祭日<個別指定日 の優先度でスケジューリングしたアラームタイムテーブルで運用
  2. アプリケーションの見た目は時計表示機能をもつGUIでスケジューリング等の設定を行う
  3. デフォルト音源(フリーの音源素材)のほか任意のMP3音源をアラームとして使用可能
  4. ターゲット機器はPC。ただしアラーム用に占有出来るよう安価なもの

4のターゲット機器は、安価なものとしてRasberryPiにUSBスピーカーを接続した構成とした(今となってはRaspberryPi自体が入手困難な貴重なデバイスですが・・・w)

ターゲットはLinuxだけど、GUIなどデバッグはMacOSでやりたいのでマルチプラットフォームGUIのTauriが最適ということで、アプリケーションフレームワークとしてTauriを採用という、まさに取って付けたような結論ありきの流れ。。

TauriであればフロントエンドはWebと同等なので3の音源再生も造作も無しと。

で、一通りの機能を実装して完成したアプリケーションが左のレポジトリ。MacOSでの実行画面が下記になります。折角Bootstrapを使用したフロントエンドなのにこのデザインセンスよwww。とりあえずは実用的には動けばいいや的なものになっています。

スケジュール管理画面
タイムテーブル編集状態

アラームスケジュールの設定は左記のように、デフォルトで平日<休日といった優先度の低い日程が定められており、ユーザーはスケジュールを特に変更したい日を個別に追加する。

それぞれのスジュール日程に対してアラームセットと呼ばれるタイムテーブルを設定する。アラームセットは個別データとなっているので、別のスケジュール日程に同じアラームセットを適用することが可能(左下画面)。

画面は省略するが、祝祭について、2023年まではデフォルト設定してあるが、それ以降はユーザーで追加する(祝祭日でーたをCSVファイルで一括登録も可能)。

しかし、さすがに工夫無しで使用するとUIがWEBアプリのそのまんまになってしまいますね。もう少しデスクトップアプリっぽくしたかったのですが。。。

あと、マルチプラットフォームGUIで苦労した所として、Linux環境だけ音源再生出来ない問題がありました。

もともとWEBアプリでは、ページロード後にユーザー操作無しには音声再生出来ないような仕組みになっている(広告アプリなどが勝手に大音量で鳴らし始めたら大変なので)ので、同じエンジンを使用しているTauriでも初期には同様の問題があったらしいが、デスクトップアプリでそれでは不便だろうとのことで対策されているはず?なので、本家GithubのDiscussionに質問を投げているが、イマイチ反応は無いですねーΣ(゚д゚lll)

一応自前で解決しているが、何か原因が良く分からない解決方法でした(笑)

これをキッカケに本家のGithubに爪痕でも残せたら、、、と思ってたけど。うーん高い壁なのかなw
(機械翻訳のいい加減な英文投稿でうまく伝わらなかったのかもwww)

Tauriアプリケーション開発メモ(Webpack+Typescript+React)

いままで覚えるのが面倒とかいう理由でフロントエンドのフレームワークを使用せずに素のTypescriptでゴリゴリ書いていたが、そろそろ限界というかネタ不足と言うことで、TauriアプリケーションにおいてReactをフロント側のフレームワークとして使用することにしました。

ということで、Typescript+Reactをフロントエンドに使用したTauriアプリケーション開発準備メモ。

以下のような手順でアプリケーションを作成します。

  1. プロジェクトディレクトリの作成
  2. フロントエンドの準備
  3. Tauri (Rust)プロジェクトの作成
  4. コンパイル

プロジェクトディレクトリの作成

アプリケーション名に準ずるディレクトリを作成して、移動します。

フロントエンドの準備

npmの初期化を行い、Webpack、Typescript、 Sass、Reactをインストールします

$ npm init -y
$ npm install -D webpack webpack-cli webpack-dev-server
$ npm install -D typescript ts-loader sass style-loader css-loader postcss-loader sass-loader
$ nom install -D babel-loader @babel/core @babel/preset-env
$ npm install -S react react-dom @types/react @types/react-dom
$ npm install -D @babel/preset-react

package.jsonに デバッグ/ビルド script を追加

scripts: {
    "build": "webpack",
    "dev": "webpack serve"
},

typescriptの設定初期化

$ npx tsc --init --target es6 \
                --module es2022 \
                --lib es2022,DOM \
                --sourceMap true \
                --moduleResolution node \
                --allowSyntheticDefaultImports true \
                --resolveJsonModule true \
                --jsx react

webpack.config.js作成

webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: './src/index.tsx',
  output: {
    path: path.join(__dirname, "dist"),
    filename: "main.js",
  },
  module: {
    rules: [
      {
        test: /\.(ts|tsx)$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env', '@babel/react']
            },
          },
          {
            loader: 'ts-loader',
            options: {
              configFile: path.resolve(__dirname, 'tsconfig.json'),
            },
          },
        ],
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
        ],
      },
      {
        test: /\.scss$/,
        use: [{
          loader: 'style-loader'
        }, {
          loader: 'css-loader'
        }, {
          loader: 'postcss-loader',
        }, {
          loader: 'sass-loader'
        }]
      },
      {
        test:/\.SVG$/,
        type: 'asset/source',
      },
      {
        test:/.(gif|svg|png|jpg|jpeg|JPG)$/,
        type: 'asset', /**  default <8kb: inline, >8kb: resource  */
        generator: {
          filename: 'image/[hash].[ext]',
        }
      },
      {
        test:/.(mp3|wav|aac)$/,
        type: 'asset/resource',
        generator: {
          filename: 'audio/[hash].[ext]'
        }
      },
      {
        test:/.(woff|woff2|eot|ttf|otf)$/,
        type: 'asset/resource',
        generator: {
          filename: 'font/[hash].[ext]'
        }
      }
    ]
  },
  devServer: {
    static: {
      directory: path.join(__dirname, 'dist'),
    },
    port: 3000,
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".json", ".wasm"]
  },
  experiments: {
    outputModule: true,
    asyncWebAssembly: true,
  },
  target: 'web',
};

ソースディレクトリ(src)と生成物ディレクトリ(dist)の作成とテンプレ作成

dist/index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Tauri App Title</title>
  <script async type="module" src="main.js"></script>
</head>
<body>
  <div id="root"></div>
</body>
</html>
src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';

new Promise(resolv => window.onload = resolv).then(() => {
    const root: ReactDOM.Root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
    root.render(
        <div>sample contents</div>
    );
});

Tauriプロジェクトの作成

$ cargo tauri init

ターゲットディレクトリを../distとするほか、フロントエンドが先に作成されているのでデフォルト通りでOK。

コンパイル&デバッグ

$ cargo tauri dev

その他UI構築にbootstrapを使いたい場合は
$ npm install -D bootstrap @types/bootstrap react-bootstrap @types/react-bootstrap
と、当然Tauri APIを使用するなら
$ npm install -D @tauri-apps/api
など・・・・とな。

TauriによるRustデスクトップアプリケーション開発

Webpack(Typescript、Sass)フロントエンド開発環境

Rustと関連クレートをインストールしたら、アプリケーションプロジェクトを作成します。

通常のRustプロジェクトであれば、cargo init [project name]とするのですが、Tauri開発環境ではフロントエンドとの同時進行開発となるので、まずはプロジェクトのトップディレクトリの作成から

$ mkdir [project name]
$ cd [project name]

として作業を始めます。ここではプロジェクト名を単純に「proj」としておきます。

次に下記コマンド cargo tauri init で対話的にプロジェクトを作成します。

  • プロジェクト名
  • タイトル
  • フロントエンドのアセットディレクトリ。生成されるRustプロジェクトからの相対パスとなるので、../dist の様にする。
    この中にあるindex.htmlがフロントエンドデザインの起点となる
  • 開発用サーバーの設定。上記アセットと同じパスにしても良いが、本番構成と同じTauiの内部サーバーを使用するので毎回コンパイルが必要となるため、開発用サーバーを立ち上げてそのURLを記載する方が便利かも。(後述のwebpack-dev-server)を使用するのが良いと思います。
proj$ cargo tauri init
What is your app name? · [project name]
What should the window title be? · [project name]
? Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that w
Where are your web assets (HTML/CSS/JS) located, relative to the "<current dir>/src-tauri/tauri.conf.json" file that will be created? · ../dist
What is the url of your dev server? · ../dist または http://localhost:8080

プロジェクト名その他は後からでも変更可能のようです。(src-tauri/tairu.conf.json)

設定したアセットディレクトリと、その中にindex.htmlを作成します。内容はとりあえずテンプレート的な感じで。(VScodeなら1文字目に’!’を入力して[tab]キーでドーンとテンプレートが入力されます)

proj$ mkdir dist
proj$ touch dist/index.html
proj
├── dist
│   └── index.html
└── src-tauri
    ├── Cargo.lock
    ├── Cargo.toml
    ├── build.rs
    ├── icons
    │   ├── 128x128.png
    │   ├── ........
    │   ├── 32x32.png
    │   ├── icon.icns
    │   ├── icon.ico
    │   └── icon.png
    ├── src
    │   └── main.rs
    └── tauri.conf.json		

ここまでのディレクトリ構成は左のようになります。

src-tauri以下がRustのプロジェクトです。dist以下がフロントエンドを構成するアセットディレクトリで、ここからWEBアプリと同様なノリで進めることが出来ます。

ちなみにこの段階で、tauriアプリとしてコンパイル&実行が出来ます。

proj$ cargo tauri dev

しかし、まだ真っ白なウインドウだけのアプリですが・・・・w

また、プロジェクト初期化の段階で開発サーバーにhttp://localhostとか設定していた場合は、まだサーバーを立ち上げていないので何も起こりません。

ここからフロントエンドの開発環境を整えます。まずはプロジェクトのトップディレクトリでnodejsの初期化と必要なものを色々とインストールします

proj$ npm init -y
proj$ npm install --save-dev webpack webpack-cli webpack-dev-server
proj$ npm install --save-dev typescript sass ts-loader sass-loader css-loader
proj$ npm install --save-dev postcss-loader style-loader
proj$ npm install --save-dev @tauri-apps/api @tauri-apps/cli

片っ端からインストールしている感じがしないでもないですが、これだけあれば足りるでしょうか。

次にTypescriptの初期化。この辺は好みがあるかもなので、テキトウに。。。

proj$ npx tsc --init --target es6 --module ES2020 --lib ES2020,DOM --sourceMap true --moduleResolution node --allowSyntheticDefaultImports true --resolveJsonModule true

webpack.config.jsも通常のWEBアプリと同様の設定としました。

webpack.config.js

const path = require("path");
module.exports = {
  mode: "development",
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader"
      },
      {
        test: /\.css$/,
        use: [
          'style-loader',
          'css-loader',
        ],
      },
      {
        test: /\.scss$/,
        use: [
          {
            loader: 'style-loader'
          },
          {
            loader: 'css-loader'
          },
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: function () {
                  return [require('autoprefixer')];
                }
              }
            }
          },
          {
            loader: 'sass-loader'
          }
        ]
      },
      {
        test:/\.xml$/,
        loader: 'xml-loader',
      },
      {
        test:/\.html$/,
        loader: 'html-loader',
      },
      {
        test:/\.SVG$/,
        //loader: 'svg-inline-loader'
        type: 'asset/source',
      },
      {
        // https://ics.media/entry/16329/
        test:/.(gif|svg|png|jpg|jpeg|JPG)$/,
        // type: 'asset/inline',
        type: 'asset', /**  default <8kb: inline, >8kb: resource  */
        generator: {
          filename: 'image/[hash].[ext]',
        }
      },
      {
        test:/.(mp3|wav|aac)$/,
        type: 'asset/resource',
        generator: {
          filename: 'audio/[hash].[ext]'
        }
      },
      {
        test:/.(woff|woff2|eot|ttf|otf)$/,
        type: 'asset/resource',
        generator: {
          filename: 'font/[hash].[ext]'
        }
      }
    ]
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".json", ".wasm"]
  },
  experiments: {
    outputModule: true,
    asyncWebAssembly: true,
  },
  devServer: {
    static: {
      directory: path.join(__dirname, 'dist')
    },
    port: 8080,
  }
}

webpackはデフォルトではソースはsrc/index.js、生成物はdist/main.mjs(モジュール版)となっているので、上記のファイル構成に対してトップへsrcディレクトリを追加します

proj$ mkdir src
proj$ touch src/index.js

そしてdist/index.htmlに埋め込むために次の一行をHEADの最後に追加

  <script type="module" src="./main.mjs"></script>

package.jsonに実行環境のスクリプトを追加

  "scripts": {
    "build": "webpack",
    "dev": "webpack-dev-server"
  },

src-tauri/tauri.config.json を変更

  • beforeBuildCommand : Build前に実行されるコマンド、この場合Webpackコンパイルのみ
  • beforeDevCommand:デバッグ時に事前に実行されるコマンド。webpack-dev-server起動、webpackコンパイルもリアルタイムに行われる。
  • devPath:webpack-dev-serverに置き換える。これによりデバッグ状態でもフロントエンドの編集がリアルタイムに反映されるようになる
  "build": {
    "beforeBuildCommand": "npm run build",
    "beforeDevCommand": "npm run dev",
    "devPath": "http://localhost:8080/",
    "distDir": "../dist"
  },

しかし、実際のデバッグはVScode上で行われるので、.vscodeディレクトリと各種設定を行う(VScodeのデバッグでは上記tauri.config.jsonは使われないようです)

proj
├── .vscode
│   ├── launch.json
│   └── tasks.json
└── src-tauri
・・・・・
.vscode/*

launch.json

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
      {
        "type": "lldb",
        "request": "launch",
        "name": "Tauri Development Debug",
        "cargo": {
          "args": [
            "build",
            "--manifest-path=./src-tauri/Cargo.toml",
            "--no-default-features"
          ]
        },
        // task for the `beforeDevCommand` if used, must be configured in `.vscode/tasks.json`
        "preLaunchTask": "dist:dev"
      },
      {
        "type": "lldb",
        "request": "launch",
        "name": "Tauri Production Debug",
        "cargo": {
          "args": ["build", "--release", "--manifest-path=./src-tauri/Cargo.toml"]
        },
        // task for the `beforeBuildCommand` if used, must be configured in `.vscode/tasks.json`
        "preLaunchTask": "dist:build"
      }
    ]
  }

tasks.json

{
    // See https://go.microsoft.com/fwlink/?LinkId=733558
    // for the documentation about the tasks.json format
    "version": "2.0.0",
    "tasks": [
      {
        "label": "dist:dev",
        "type": "shell",
        // `dev` keeps running in the background
        // ideally you should also configure a `problemMatcher`
        // see https://code.visualstudio.com/docs/editor/tasks#_can-a-background-task-be-used-as-a-prelaunchtask-in-launchjson
        "isBackground": true,
        // change this to your `beforeDevCommand`:
        "command": "yarn",
        "args": ["dev"],
      },
      {
        "label": "dist:build",
        "type": "shell",
        // change this to your `beforeBuildCommand`:
        "command": "yarn",
        "args": ["build"],
      }
    ]
  }

コレで、VScodeで開発・デバッグが出来るようになりました。

フロントエンドの開発が殆どWEBアプリのそれと同じなのが良いですね。ていうか、特にバックエンドの要らないアプリだとjavascriptだけでデスクトップアプリが出来てしまうという。逆に殆どをRsutによるバックエンドだけにしてもアプリケーションを構成出来たり、アプリの用途や開発者の好みでどちら寄りにも出来てしまいそうなのが面白い。

TauriによるRustデスクトップアプリ開発環境構築メモ

Rustのインストール

$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 全てデフォルトで作成するので下記のように、フルオートでインストール出来る
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# 下記コマンドでパスを設定
$ source $HOME/.cargo/env

Cargoで必要なモジュールをインストール

$ cargo install cargo-generate
$ cargo install cargo-update
$ cargo install cargo-edit

VScodeなどによるデバッグ用に下記ツールをインストール

$ rustup component add rls rust-analysis rust-src

ツールのアップデート

$ rustup update
$ cargo install-update --all

Tauri関連のインストール

$ cargo install kauri-cli
$ npm install --save-dev @tauri-apps/cli

とりあえずこんな感じで、MacOS、Windows、Linux(Ubuntu)でやってみました。次は簡単なアプリケーションを作成してみる

Rustアプリケーション開発(Tauri)

以前の記事でチョットだけ触れたRsut言語ですが、チェックしていたTauri(デスクトップアプリケーション開発フレームワーク)が6月16日にバージョン1.0で正式公開されたのを機に、当方でもアプリ開発のツールとして導入することにしました。

Rustのデスクトップアプリと言うか、その構成はWEBアプリに近く、Rust作成するバックエンドコアを軸にUIなどのフロントエンドは各OSが持つブラウザエンジンが担当する。従ってフロントエンドはブラウザアプリと同様に主にHTML5で書くことになり、さらに同じソースファイルでマルチプラットフォームを実現するというコンセプトだそうです。

現在対応中のプラットフォームは

  • Windows(7以降)
  • MacOS
  • Linux

で、その他AndroidやiOSへの対応もすすんでいるという(こちらが導入の目的だったりする)。

自分の触手が動いたのは上記のマルチプラットフォームが第1に上げられるが、その他

  • フロントエンドの開発がWEBアプリのそのままで、Webpack、Sass、Typescriptなどの環境がそのまま使え、あまり新しいことを覚える必要が無い
  • 最新の言語Rustで開発出来る事。これが目的の人には待望の環境かも知れないですね。

ということで久々なのに短めですが、次回よりまた開発環境の構築などからメモしていこうと思います。

ES6とDOMの件:Typescript編

小さな規模のアプリしか作らないこともあり、フロントエンドに関しては頑なにフレームワークを使わない勢の私は、FastAPI、webpack、wasm(Rust)までに手を出しつつも、未だに素のTypescriptで何とかしようとしている(Typescriptの段階で「素」では無いかも知れないが)。

とくにDOMに関しては以前の記事にあるようなヘルパ関数を使用して、比較的構造的に組み立てられる仕組みを自作し、利用している。下記のように作成・追加したエレメントを編集(属性や子要素を追加する)する関数で、引数の関数の中でさらに要素を追加して属性設定やさらに子要素追加するなど、入れ子構造でコーディング出来る。

/**
 * ① 自身を引数とする関数を引数に取り、処理を行った後、自身を返す
 */
 HTMLElement.prototype.editElement = function(func) {
  func(this);
  return this;
} 
/**
 * ② appendChild(文字列引数の場合はappend)処理を行った後、自身を返す
 */
HTMLElement.prototype.appendChain = function(child) {
  if(child instanceof HTMLElement){
    this.appendChild(child);
  } else if (typeof(child) === "string") {
    this.append(child);
  }
  return this;
}
/**
 * ③ 子要素をクリアしたのち自身を返す
 */
HTMLElement.prototype.clearElement = function() {
  this.innerHTML = "";
  return this;
}
/**
 * ④ 子要素として引数のタグでエレメントを作成。第2引数の関数処理を行ってエレメントを返す
 */
HTMLElement.prototype.appendElement = function(tag, func) {
  const elm = document.createElement(tag);
  if(func) func(elm);
  this.appendChild(elm);
  return this;
}
/**
 * ⑤ 第一引数のタグで単独のエレメントを作成し、第2引数の関数処理を行い作成した要素を返す
 */
Document.prototype.createElementEdit = function(tag, func) {
  const elm = document.createElement(tag);
  if(func) func(elm);
  return elm;
}

  let body = document.querySelector('article').clearElement();
  body.editElement(bdy => {
    bdy.className = 'bg-secondary';
    bdy.appendElement('p', para => {
      para.style.color = 'red';
      para.textContent = 'test string';
    });
  });

<article class="bg-secondary">
  <p style="color: red;">
   test string
  </p>
</article>

これをTypescriptに移植するのは結構面倒だったので、今回のメインネタにしました。究極には全部anyの型にして押し切ってしまえば動くと言えば動くんですが、せっかくならしっかり作ってエディタサポートの恩恵も得られて何ならjavascriptよりも使いやすくしてしまおうということで下記のように移植しました。

/**
 * インターフェース定義:組込みのHTMLElementやDocumentに追加するために declare global する
 */
declare global {
    interface HTMLElement {
        appendElement<K extends keyof HTMLElementTagNameMap>(tag: K, func?: (elem: HTMLElementTagNameMap[K]) => void): HTMLElement
        editElement<T extends HTMLElement>(func: (elem: T) =>  void): T
        appendChain<T extends HTMLElement>(child: HTMLElement | string): T
        clearElement<T extends HTMLElement>(): T
    }
    interface Document {
        createElementEdit<K extends keyof HTMLElementTagNameMap>(tag: K, func: (elem: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K]
    }
}
/**
 * ① 自身を引数とする関数を引数に取り、処理を行った後、自身を返す
 */
HTMLElement.prototype.editElement = function<T extends HTMLElement>(func: (elem : T) => void) : T {
    console.log(this.constructor.name)
    func(this as T);
    return this as T;
}
/**
 * ② appendChild(文字列引数の場合はappend)処理を行った後、自身を返す
 */
HTMLElement.prototype.appendChain = function<T extends HTMLElement>(child: HTMLElement | string) : T {
    if(child instanceof HTMLElement){
        this.appendChild(child);
    } else if (typeof(child) === "string"){
        this.appendChild(document.createTextNode(child));
    } else {
        throw new TypeError("parameter node: must be string or HTMLElement.");
    }
    return this as T;
}
/**
 * ③ 子要素をクリアしたのち自身を返す
 */
HTMLElement.prototype.clearElement = function<T extends HTMLElement>() : T {
    assertIsInstanceOf(this, HTMLElement);
    this.innerHTML = "";
    return this as T;
}
/**
 * ④ 子要素として引数のタグでエレメントを作成。第2引数の関数処理を行ってエレメントを返す
 */
HTMLElement.prototype.appendElement = function<K extends keyof HTMLElementTagNameMap>(tag: K, func?: (elem: HTMLElementTagNameMap[K]) => void): HTMLElement {
    assertIsInstanceOf(this, HTMLElement);
    const elm : HTMLElementTagNameMap[K] = document.createElement(tag);
    if(func) func(elm);
    this.appendChild(elm);
    return this;
}
/**
 * ⑤ 第一引数のタグで単独のエレメントを作成し、第2引数の関数処理を行い作成した要素を返す
 */
Document.prototype.createElementEdit = function<K extends keyof HTMLElementTagNameMap>(tag: K, func: (elem: HTMLElementTagNameMap[K]) => void): HTMLElementTagNameMap[K] {
    const elm : HTMLElementTagNameMap[K] = document.createElement(tag);
    func(elm);
    return elm;
}

いろいろ試行錯誤した結果、上記のように落ち着きました。Typescirptでメソッド追加するためにはInterfaceを定義する必要があるけど、HTMLElementなど組込みのクラスに対して普通にインターフェースを追加で定義しようとしてもエラーになるようなので、上記のようにdeclare globalで定義する必要があるようです。あとは普通にprototypeで追加していくのだが、これも型定義が割と面倒で、createElement系などは引数のタグ名で戻り値の型が決まる変則ジェネリクスなので、typescriptのパッケージの中身を参考にほぼコピペしました(④、⑤)。

また、①や②は、関数でエレメントを編集したり、子要素を追加して自身を返す関数で、メソッドチェーン用に使用していたのですが、HTMLElementへのインターフェースとして実装している関係でthisが常にHTMLElementになってしまう問題があります。例えば<a></a>のリンクタグを追加しようとしても、createElementしたときはHTMLAncorElementであっても関数内で利用する時はHTMLElementとして扱われるのでthis.hrefメンバーが使えないなど。この場合、一旦unknownにしてHTMLAnchorElementにキャストし直す(element as unknown as HTMLAnchorElement)か、下記のような関数でアサートすることで解決出来ます。

/**
 * val が 指定のクラス cls のインスタンスであることをアサートする関数
 * 例えば HTMLELementにキャストされた下位クラス(HTMLInputELementなど)を、もとのクラスとしてアサート
 */
export function assertIsInstanceOf<T>(val: any, cls: new() => T) : asserts val is T {
    if(!(val instanceof cls)){
        throw new Error(`${val} is not instance of cls`);
    }
}

例)

document.createElement('a').editElement(link => {
   link.href = '#'; // ----  × ここではまだlinkはHTMLElementでhrefメンバがないのでエラー
   assertIsInstanceOf(link, HTMLAnchorElement);
   link.href = '#'; // ----  ○ HTMLAnchorElementとしてアサートされているのでOK
});

とは言ってもイチイチアサートするのも面倒な仕様なので、イマイチ移植できていない感があります。
まぁ、実際使用していると、こういう引数が長くなりがちな関数に対してメソッドチェーンを利用しても読みにくくなってしまうだけなので、これらは使用しないということで封印の方向で進んでいますw
エレメントをサクッと作成するdocument.createElementEdit(tag, func)と、構造的にサクッと子要素を追加するelement.appendElement(tag, fuc)だけあれば全然問題がないという結論に達しつつある感じ。

Webpackその他

メモ記事とはいえなかなか書いてる時間がとれず一月以上の間が空いてしまった。FastAPIによるバックエンド実装からTypescriptとSASSによるフロントエンド実装の勉強に入った所、WebAssemblyとその有力言語のRustに興味が写ってしまい、そのあたりを勉強しながらフロントエンドに組み込む技術を調査していたら、Webpackという技術に突き当たった。

WebAssemblyとRustに関しては別の機会に書くとして、今回はWebpack。いままで何度か単語程度に見かけはしたが、なんとなくお腹一杯感でスルーしていたが、必要に応じて調べてみると目からウロコ的な技術だった。

従来の静的コンテンツ(ブラウザ側で処理をする所謂フロントエンド)は、HTMLベースでスタールシート(CSS)やJavascriptを必要に応じて読み込む形式だったが、Webpack化することで、大まかには一つのJavascriptにまとめられる。

例えば下記のようなindex.htmlでは

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <!-- 省略 -->
  <style type="text/css">
    @import "/static/css/bootstrap-custom.css";
  </style>
  <script type="module">
    import { Alert, Button, Carousel, Collapse, Dropdown, Modal, Offcanvas, Popover, ScrollSpy, Tab, Toast, Tooltip } from "/static/js/bootstrap.esm.js"
  </script>
</head>
<body class="base">
  <script type="module">
    import {init} from "/static/js/app.js"
    window.addEventListener("load", init());
  </script>
</body>
</html>

6行目〜でbootstrapのカスタムスタイルシート(これは別途SASSでコンパイルしている)を読み込んで、その下9行目〜でbootstrap用のスクリプトをモジュールとして読み込んでいる。Bodyタグを読み込んだあとで14行目〜でシングルページアプリケーション用スクリプトapp.jsを読み込んでページロード完了後にinitを実行(window.addEventListener)。app.jsもTypescriptのソースから別途コンパイルしておりアプリケーションの規模によっては多くのモジュールに分かれてロードされる。

こういった構成のものが、Webpack化することで下記のように出来る

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <!-- 省略 -->
    <script src="/static/dist/main.js"></script>
</head>
<body class="base">
</body>
</html>

6行目のmain.js を読み込むだけとなる。main.jsはWebpackによってコンパイルされたjavascriptで読み込むべきリソースが全てパック化された生成物となる。ソースファイルは一般にindex.jsとされ、下記のような内容となる

import '../scss/bootstrap-custom.scss';
import 'bootstrap/dist/js/bootstrap.esm.js';

import {init, build} from '../ts/app';

init();

window.addEventListener("load", () => {
  build();
});

一行目でbootstrapのカスタムスタイルシートを読み込むが、ここではscssのソースファイルを直接インポートして、WebpackのプラグインでSassコンパイラを起動する。生成したスタイルシートをインポートすると呼び出し側のHTMLのHEADタグ内にSTYLEタグを生成して直接コーディングするため、CSSファイルは生成されない。

2行目はbootstrap用のスクリプトをインポート。4行目でシングルページアプリケーションスクリプトを読み込むが、これもTypescriptのソースを直接していして内部でコンパイラを起動している。また、従来構成と違って読み込んだ時点ではBODYタグが生成されていないため、DOM関連の初期化があると失敗する。DOM関連の初期化はloadイベントをトリガとして始めるよう工夫が必要。

とりあえず、Webpackを導入するとこうなるというだけのメモで、Webpackの具体的な設定などはまた別の機会に。

FastAPI認証機構の追加

小規模djangoページの置き替えの為にFastAPIアプリケーションの勉強と実装実験を行っているが、次に用意したものは管理ページなどへアクセスするためのユーザー認証機構。前述の通りFastAPIはAPIの提供に特化したシンプルなフレームワークなので実用的な機能などは自前で準備する必要がある。かといって何も情報が無いかと言えばそうでも無く、公式のドキュメントやチュートリアルに必要そうなものが一通りまとめてあるので(例えばセキュリティの項目)、それをチマチマ実装していくという実に勉強になる仕組みが用意されている。

ドキュメント・チュートリアルで今回参考(コピペ)にしたのもは主に次の二点

  • セキュリティ
    • パスワード(およびハッシュ化)によるOAuth2、JWTトークンによるBearer
    • ユーザー認証の基本的な構造サンプル
  • SQL database(データベースアクセス)
    • SQLAlchemyを使用してFastAPIのパス構文に接続する方法

以上を組み合わせてユーザー情報をデータベースに保存し、ユーザー認証を行うような仕組みが実現出来る。

ただし、この段階ではAPIとしての実装なので実際に使用できるようにするには、フロントエンドの実装が必要となる。Djangoではバックエンド寄りの実装でHTMLテンプレートを用いてのUIだったが、今回のUIはFastAPI対応ではせっかくなのでシングルページの実装としてjavascript(Typescript)での実装としている。

こんな感じのログイン画面を準備してみました。ユーザー追加の画面や実際の管理画面は未実装だが、同じようなノリで追々追加していきます。

あと、このままではトークンの期限が切れる度に再認証になるので、セッションIDによるリフレッシュトークンの実装で同じ端末からはなるべくログインし続けられるような機構の導入を検討している(実装の手段はともかく見た目くらいはDjangoと同じくらいにしたいw)。

bootstrapをほぼ素で利用しているだけで何となく見た目だけでも良い感じなのが嬉しいですね。

FastAPI環境構築

前回の投稿にてDjangoの置き替えとしてFastAPIの勉強を始めてかなり時間が経ってしまいましたが、まずはDjangoの時と同じようにシングルページWEBアプリケーションのテンプレートのような形で勉強しようと思い、同様にDockerの環境構築からの覚え書きです。

  • PythonイメージベースのDockerfile
    • nodejsをインストールして、モジュールとしてTypescript、Bootstrap、SASSを追加
    • Pythonライブラリとしてfastapi関連ライブラリをインストール
    • 実行ユーザーappを作成してsudo権限を与える
  • nginxプロキシを追加したdocker-compose.yml
    • /codeをマウントしてここにアプリを配置する
    • startupスクリプトを実行させる(環境変数設定とtypescript、sassコンパイラの常駐設定、APIサーバー:uvicornのデーモン起動)

上記プロジェクトを用いてdocker-compose up -dすると、サーバーが起動してブラウザからhttp://localhost:8080/でアクセス出来るようになります。

WEBアプリケーションの実体はapp/main.pyです。サンプルではある程度サンプルページが表示されるまで作り込んでしまっていますが、覚え書きとして一番単純なアプリケーションから

from fastapi import FastAPI

app = FastAPI()

@app.get('/')
async def index():
    return {'detail': 'Hello FastAPI World.'}

として、保存。ブラウザからhttp://localhost:8080/で読み込むと、

一番単純なFastAPIアプリ

こんな感じで表示されます。

app/main.pyの1行目でライブラリを読み込んで、アプリケーションインタンスの生成(3行目)、5行目はHTTPリクエストメソッドを表す関数デコレータです。例ではルートパスに対するGETリクエスト。その下のdefが関数定義で、名前は分かりやすければ何でも良いです。この関数の戻り値がHTTPのレスポンスとしてブラウザに返されます。

見て分かるように、このアプリケーションで返されるレスポンスはHTMLではないため、結果の見た目が変です。

FastAPIはDjangoと異なりAPIが主体なのでデフォルトではJSONで返すのが基本のようです。Ajaxなどでデータのやりとりをするのに向いている感じ。

HTTP通信を使用したアプリケーションでデータのやりとりを行い、クライアント側で見た目を準備するような用途ではこのままで良さそうですが、普通のWEBアプリのようにブラウザから利用したい場合はひと工夫が必要です。

少し調べた感じでは、二通りの方法があるようです。

  1. Jinja2Templatesテンプレートライブラリを利用する
    • FastAPIのもととなるStarletteやFlaskでも使用されており、Djangoのテンプレートと同様に変数埋込で動的なHTML文書を返すことが出来る。
  2. スタティックマウントしたディレクトリでhtmlを有効にする
    • from astapi.staticfiles import StaticFiles
    • app.mount(“/html”, StaticFiles(directory=”/code/html”, html=True))
    • 上記のように追加することで http://localhost:8080/html/ で/code/htmlに保存されたindex.htmlが表示されるのでそのページに仕込んだjavascriptからFastAPIのAPIを利用することで、シングルページアプリケーションを実現することが出来る。

サンプルでは1の方法を採用して、ルートページを表示させるようにしている。2の方法をにするには@app.get(‘/’)の部分を他のパスに変更するか、レスポンスごと削除し、スタティックマウント先を’/’ルートにすると良い。

サンプルのようにHTMLテンプレートを利用したレスポンスがこちら。

Bootstrapでチョッピリレイアウトしてみた。

最初は2の方法が良いなと思ってみたけど、サンプルにあるようにエラーベージをHTMLでちゃんと表示させるにはテンプレートを使用した方が便利だし、どうせテンプレート使うならルートページもテンプレートで行こう!という結論となりました。

FastAPIの導入

前回記事より一ヶ月ほど経過してしまいましたが、また思いつきで調査勉強の没頭していたという適当な感じで休んでいました。やっぱりこういうものを習慣化するのは難しい……。

現在の作業項目としてdjangoによるがWEBアプリテンプレートがあるが、シングルページアプリケーションへの移行を考慮するなど次第にバックエンド側の比重が軽くなってきた。このためもう少し軽いバックエンドフレームワークをと言うことで検討を行いFastAPIの採用を検討することにした。

まだ不勉強なので、間違った比較かも知れないが下記のように比較できると思う。

djangoFastAPI
バックエンド主体の比較的規模の大きなWebアプリケーションに向いている
主にHTMLレスポンスで動的にWebページを表示できる
フロントエンド主体のWebアプリケーションに向いている
主にJSONレスポンスでWebAPIに特化。ブラウザに表示するためには別途テンプレートや静的HTML文書を用意し、API利用によるWebアプリを実現する
ユーザー認証、データベース利用など一般的なバックエンド機能が一通り揃っている状態。基本状態ではリクエストに応じてJSONオブジェクトを返すだけのAPIサーバー。
バックエンド機能はチュートリアルに沿って進めればある程度のものが出来る。
機能追加は容易ではあるが、ある程度の調査が必要なのと、複雑なライブラリ構造の理解がないと敷居が高い印象構造が比較的単純で分かりやすいため、機能追加は容易。ただしそれなりの調査も必要。
簡単な比較

例えば、Djangoではユーザー認証や管理画面が最初から用意されているが、それのデザインや振る舞いを変えたり認証方法をカスタムしたりするためには、フレームワークの約束事に沿ったカスタム方法を調査するなど、かなりの労力が必要になるが、FastAPIはそもそも準備されていないので、自分で作らなくてはならない。そのため後から機能追加などのカスタムは非常に容易という事になる(ただし出来上がりもそれなりのものになってしまうのは自己責任ということで)。また、フレームワーク的な約束もあまり細かくないので、作っていくうちにコードの構成が分かりにくくなってしまう危険もあるのではと思う。

実験用のサーバー構成は、色々解説があるが、こちらではもともとのDjangoテンプレートのDocker環境があるのでそちらをベースにDocker環境の構築を行う。

次回はFastAPI導入の準備としてDocker環境構築のメモを残しておきたいと思う。

FastAPIでDjangoのテンプレートアプリと同等のものを実現しようとすると、認証の仕組み、データベースアクセスの仕組みなどDjangoでは組み込まれていたものを個別に調査・勉強・実装を行わないといけない。
大変そうだけど、汎用的な仕組みの理解なので色々役に立ちそうと、前向きに考えておこう。