らいふうっどの閑話休題

興味のあることをゆる~く書いていく

appwrite で始める Angular 開発の紹介

appwrite で始める Angular 開発の紹介

今回はご縁があり、今年2月からジョインした Classi 株式会社さんと ng-japan 2つの Advent Calendar の初クロスポストです。

https://miro.medium.com/max/1400/1*-UjB7xzVXmmk4fBFVQMExQ.png

Angular Advent Calendar 2022 17日目

qiita.com

Classi developers Advent Calendar 2022 17日目

adventar.org

Brandon Roberts さんの記事:Building a Realtime Chat Application Using Angular and Appwrite 🤓を引用して紹介したいと思います。

目次

セルフホスト可能な OSS の BaaS(Backend as a Service) プラットフォームです。
※Banking as a Service(金融機関系のサービス)と勘違いしそうですが今回は、 Backend になります。

  • 以下のコマンドで、docker 環境を構築します。
  • ※ 例はUnix(macOS)の場合です。他にもWindows用コマンドもあります。
# Unix(macOS)
docker run -it --rm \
    --volume /var/run/docker.sock:/var/run/docker.sock \
    --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
    --entrypoint="install" \
    appwrite/appwrite:1.1.2

上記コマンドで、 appwrite の docker 環境は構築できているので
http://localhost/ にアクセスします。

  • Create Database クリック
  • custom Database ID に 「chat」と入力します。
  • name に 「Chat」と入力します。
  • Create ボタンをクリックします。
  • 作成イメージ

以下の表に基づいて Collection を作成します。

key size required array
user 32 true false
message 10000 true false
  • 作成イメージ

  • ドキュメントがコレクションで作成されると、ドキュメントが作成および更新されたときの追加のメタデータも含まれます。
  • このメタデータは、クエリ、同期、およびその他のユース ケースに使用できます。
  • サービスの切り替え、使用する OAuth プロバイダーの選択など、他のことを行うことができます。
  • このチャット アプリケーションでは匿名認証が使用されており、これもデフォルトで有効になっています。

github.com

# git clone します。
$ git clone git@github.com:brandonroberts/appwrite-angular-chat.git

# パッケージのインストール
$ yarn install

# ローカル起動
$ yarn start

Angular プロジェクトで Appwrite を使用するのに下記の環境変数を構成します。

  • Appwrite のエンドポイント
  • Appwrite のプロジェクト
  • Appwrite のコレクション
  • 実装イメージ

// src/environments/environment.ts を更新
export const environment = {
 endpoint: 'http://localhost/v1',
 projectId: 'ngchat', // 上記で入力したプロジェクトID
 databaseId: 'chat', // 上記で入力したデータベースID
 chatCollectionId: 'messages', // 上記で入力したコレクションID
 production: false
};

  • 実装イメージ

// src/appwrite.ts を設定

import { inject, InjectionToken } from '@angular/core';
import { Account, Client as Appwrite, Databases } from 'appwrite';
import { environment } from 'src/environments/environment';

// appwrite 設定モデル定義
interface AppwriteConfig {
  endpoint: string; // 上記で入力した API エンドポイント
  projectId: string; // 上記で入力したプロジェクトID
  databaseId: string; // 上記で入力したデータベースID
  chatCollectionId: string; // 上記で入力したコレクションID
}

// appwrite 設定モデル定義
export const AppwriteEnvironment = new InjectionToken<AppwriteConfig>(
  'Appwrite Config',
  {
    providedIn: 'root',
    factory() {
      const { endpoint, projectId, databaseId, chatCollectionId } = environment;
      return {
        endpoint, // environment.ts の API エンドポイント
        databaseId, // environment.ts のデータベースID
        projectId, // environment.ts の プロジェクトID
        chatCollectionId, // environment.ts のコレクションID
      };
    },
  }
);

// appwrite の環境変数を設定して、他ののサービスにDI 出来るように実装
export const AppwriteApi = new InjectionToken<{
  database: Databases;
  account: Account;
}>('Appwrite SDK', {
  providedIn: 'root',
  factory() {
    const env = inject(AppwriteEnvironment);
    const appwrite = new Appwrite();
    appwrite.setEndpoint(env.endpoint); // environment.ts の API エンドポイント
    appwrite.setProject(env.projectId); // environment.ts のプロジェクトID

    const database = new Databases(appwrite, env.databaseId); // environment.ts の databaseId
    const account = new Account(appwrite);

    return { database, account };
  },
});

  • Auth サービス src/auth.service.ts の実装します。
    • ログイン
    • 認証ステータスの確認
    • ログアウト
  • 実装イメージ

import { inject, Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Models } from 'appwrite';
import { BehaviorSubject, concatMap, from, tap, mergeMap } from 'rxjs';
import { AppwriteApi } from './appwrite';

@Injectable({
  providedIn: 'root',
})
export class AuthService {
  // Appwrite API の DI
  private appwriteAPI = inject(AppwriteApi);
  private _user = new BehaviorSubject<Models.User<Models.Preferences> | null>(
    null
  );
  readonly user$ = this._user.asObservable();

  constructor(private router: Router) {}

  // ログイン
  login(name: string) {
    const authReq = this.appwriteAPI.account.createAnonymousSession();

    return from(authReq).pipe(
      mergeMap(() => this.appwriteAPI.account.updateName(name)),
      concatMap(() => this.appwriteAPI.account.get()),
      tap((user) => this._user.next(user))
    );
  }

  // ログイン判定
  async isLoggedIn() {
    try {
      const user = await this.appwriteAPI.account.get();
      this._user.next(user);
      return true;
    } catch (e) {
      return false;
    }
  }

  // ログアウト
  async logout() {
    try {
      await this.appwriteAPI.account.deleteSession('current');
    } catch (e) {
      console.log(`${e}`);
    } finally {
      this.router.navigate(['/']);
      this._user.next(null);
    }
  }
}

  • チャット送信サービス src/chat.service.ts の実装します。
  • Appwrite 環境変数を注入
  • チャット メッセージのオブザーバブルを設定します。
  • Appwrite SDK を使用して メッセージ コレクションからチャット メッセージを読み込みます。
  • 現在ログインしているユーザーを取得して、 メッセージ コレクションにチャット メッセージを追加します。
  • 誰でも閲覧できるようにドキュメントにアクセス許可を割り当てますが、特定のユーザーのみが更新/削除可能です。
  • 実装イメージ

import { inject, Injectable } from '@angular/core';
import { Models, RealtimeResponseEvent } from 'appwrite';
import { BehaviorSubject, take, concatMap, filter } from 'rxjs';

import { AppwriteApi, AppwriteEnvironment } from './appwrite';
import { AuthService } from './auth.service';

export type Message = Models.Document & {
  user: string;
  messageText: string;
};

@Injectable({
  providedIn: 'root',
})
export class ChatService {
  private appwriteAPI = inject(AppwriteApi);
  private appwriteEnvironment = inject(AppwriteEnvironment);

  private _messages$ = new BehaviorSubject<Message[]>([]);
  readonly messages$ = this._messages$.asObservable();

  constructor(private authService: AuthService) {}

  // メッセージ読み込み
  loadMessages() {
    this.appwriteAPI.database
      .listDocuments<Message>(
        this.appwriteEnvironment.chatCollectionId,
        [],
        100,
        0,
        undefined,
        undefined,
        [],
        ['ASC']
      )
      .then((response) => {
        this._messages$.next(response.documents);
      });
  }

  // メッセージ送信
  sendMessage(message: string) {
    return this.authService.user$.pipe(
      filter((user) => !!user),
      take(1),
      concatMap((user) => {
        const data = {
          user: user!.name,
          messageText: message,
        };

        return this.appwriteAPI.database.createDocument(
          this.appwriteEnvironment.chatCollectionId,
          'unique()',
          data,
          ['role:all'],
          [`user:${user!.$id}`]
        );
      })
    );
  }

  // メッセージ受信待機
  listenToMessages() {
    return this.appwriteAPI.database.client.subscribe(
      `databases.${this.appwriteEnvironment.databaseId}.collections.${this.appwriteEnvironment.chatCollectionId}.documents`,
      (res: RealtimeResponseEvent<Message>) => {
        if (
          res.events.includes(
            `databases.${this.appwriteEnvironment.databaseId}.collections.messages.documents.*.create`
          )
        ) {
          const messages: Message[] = [...this._messages$.value, res.payload];

          this._messages$.next(messages);

          setTimeout(() => {
            document.getElementById(`${res.payload.$id}`)?.scrollIntoView();
          });
        }
      }
    );
  }
}

ログインページ src/app/login.component.ts の実装します。

  • AuthService を 使用して、提供された名前で匿名認証を使用してログイン出来る設計です。
  • 認証が成功すると、チャット ページにリダイレクトされる設計です。
  • サンプルは、login.component.ts に typescript, css, html を全て実装しています。
  • 実装イメージ

import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { tap } from 'rxjs';

import { AuthService } from './auth.service';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <div class="app-container">
      <div class="content">
        <span class="appwrite-chat">Angular Chat</span>

        <div class="login-container">
          <form [formGroup]="form" class="login-form" (ngSubmit)="login()">
            <p class="login-name">
              <label for="name">Name</label>

              <input
                type="text"
                id="name"
                formControlName="name"
                placeholder="Enter Name"
              />
            </p>

            <button type="submit">Start Chatting</button>
          </form>
        </div>
      </div>
    </div>
  `,
  styles: [
    `
      .login-container {
        width: 442px;
        height: 200px;
        padding: 24px;
        display: flex;
        flex-direction: column;

        background: #ffffff;
        border: 1px solid rgba(232, 233, 240, 0.5);
        box-sizing: border-box;
        box-shadow: 0px 20px 24px -5px rgba(55, 59, 77, 0.1),
          0px 8px 24px -6px rgba(55, 59, 77, 0.1);
        border-radius: 16px;
      }

      .login-form {
        display: flex;
        flex-direction: column;
        padding: 0;
        margin: 0;
      }

      .login-name,
      .login-room {
        display: flex;
        flex-direction: column;
        align-items: stretch;
        padding-right: 4px;
        padding-bottom: 4px;
        width: 100%;
        margin: 8px 0 8px 0;
      }

      .login-name input,
      .login-room input {
        background: #ffffff;
        border: 1px solid #e8e9f0;
        box-sizing: border-box;
        border-radius: 4px;
        height: 3rem;
      }

      ::placeholder {
        position: static;
        height: 24px;
        left: 16px;
        top: 12px;
        font-family: 'Inter';
        font-style: normal;
        font-weight: 400;
        font-size: 16px;
        line-height: 150%;
        color: #616b7c;
      }

      span.join-room {
        padding-bottom: 8px;

        font-family: 'Poppins';
        font-style: normal;
        font-weight: 500;
        font-size: 20px;
        line-height: 150%;

        color: #616b7c;
      }

      .login-container button {
        display: flex;
        flex-direction: row;
        justify-content: center;
        align-items: center;
        padding: 12px 20px;
        color: #fcfcff;
        width: 100%;

        background: #da1a5b;
        border: 1px solid #c00d53;
        box-sizing: border-box;
        border-radius: 4px;
      }
    `,
  ],
})
export class LoginComponent {
  form = new FormGroup({
    name: new FormControl('', { nonNullable: true }),
  });

  constructor(
    // AuthService 定義
    private authService: AuthService,
    private router: Router
  ) {}

  // ログイン処理
  login() {
    const name = this.form.controls.name.value;

    this.authService
      .login(name)
      .pipe(
        tap(() => {
          this.router.navigate(['/chat']);
        })
      )
      .subscribe();
  }
}

チャットページ src/app/chat.component.ts の実装します。

  • AuthService と ChatService 次のことを行います。
    • 現在ログインしているユーザーを使用します。
    • チャット メッセージの observable を購読します。
    • ngOnInit コンポーネントでチャット メッセージを読み込みます 。
    • ChatServiceを使用して、入力フィールドのメッセージを送信します 。
    • チャットページからログアウトします。
  • サンプルは、chat.component.ts に typescript, css, html を全て実装しています。
  • 実装イメージ

import { CommonModule } from '@angular/common';
import { Component, OnInit, OnDestroy } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { tap } from 'rxjs';

import { ChatService } from './chat.service';
import { AuthService } from './auth.service';

@Component({
  selector: 'app-chat',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule],
  template: `
    <div class="chat-container" *ngIf="user$ | async as vm; else loading">
      <div class="chat-header">
        <div class="title">Let's Chat</div>
        <div class="leave" (click)="logout()">Leave Room</div>
      </div>

      <div class="chat-body">
        <div
          id="{{ message.$id }}"
          *ngFor="let message of messages$ | async"
          class="message"
        >
          <span class="name">{{ message.user }}:</span>
          {{ message.messageText }}
        </div>
      </div>

      <div class="chat-message">
        <form [formGroup]="form" (ngSubmit)="sendMessage()">
          <input
            type="text"
            formControlName="message"
            placeholder="Type a message..."
          />
          <button type="submit" class="send-message">
            <svg
              class="arrow"
              width="24"
              height="24"
              viewBox="0 0 24 24"
              fill="none"
              xmlns="http://www.w3.org/2000/svg"
            >
              <path
                d="M13.0737 3.06325C12.8704 2.65671 12.4549 2.3999 12.0004 2.3999C11.5459 2.3999 11.1304 2.65671 10.9271 3.06325L2.52709 19.8632C2.31427 20.2889 2.37308 20.8001 2.67699 21.1663C2.98091 21.5325 3.4725 21.6845 3.93007 21.5537L9.93006 19.8395C10.4452 19.6923 10.8004 19.2214 10.8004 18.6856V13.1999C10.8004 12.5372 11.3376 11.9999 12.0004 11.9999C12.6631 11.9999 13.2004 12.5372 13.2004 13.1999V18.6856C13.2004 19.2214 13.5556 19.6923 14.0707 19.8394L20.0707 21.5537C20.5283 21.6845 21.0199 21.5325 21.3238 21.1663C21.6277 20.8001 21.6865 20.2889 21.4737 19.8632L13.0737 3.06325Z"
                fill="#373B4D"
              />
            </svg>
          </button>
        </form>
      </div>
    </div>

    <ng-template #loading>Loading...</ng-template>
  `,
  styles: [
    `
      .chat-container {
        width: 100%;
        height: 98vh;
        display: flex;
        flex-direction: column;
        background: #fcfcff;
      }

      .chat-header {
        display: flex;
        flex: none;
        flex-direction: row;
        justify-content: space-between;

        background: #ffffff;
        box-shadow: 0px 4px 40px rgba(55, 59, 77, 0.08);
        width: 100%;
        height: 60px;
      }

      .chat-header .title {
        position: absolute;
        width: 89px;
        height: 27px;
        left: 32px;
        top: 17px;
        font-family: 'Poppins';
        font-style: normal;
        font-weight: 500;
        font-size: 18px;
        line-height: 150%;
        color: #373b4d;
      }

      .chat-header .leave {
        display: flex;
        flex-direction: row;
        justify-content: center;
        align-items: center;
        padding: 10px 16px;
        cursor: pointer;

        position: absolute;
        width: 130px;
        height: 41px;
        left: 88%;
        top: 10px;

        background: #fcfcff;

        border: 1px solid #e8e9f0;
        box-sizing: border-box;
        border-radius: 4px;
      }

      .chat-body {
        display: flex;
        flex-direction: column;
        flex-grow: 1;
        padding: 16px;
        overflow: auto;
      }

      span.name {
        font-family: 'Inter';
        font-style: normal;
        font-weight: 600;
        font-size: 14px;
        line-height: 150%;
        color: #373b4d;
      }

      .message {
        display: flex;
        flex-direction: column;
        justify-content: center;
        padding: 8px 8px 8px 12px;
        margin: 16px 8px 16px 12px;

        background: #ffffff;
        box-shadow: 0px 1px 4px rgba(55, 59, 77, 0.1),
          0px 1px 4px -1px rgba(55, 59, 77, 0.1);
        border-radius: 0px 8px 8px 8px;
      }

      .incoming {
        background: #e8e9f0;
        align-items: flex-end;
      }

      .chat-message {
        display: flex;
        flex: none;
        flex-direction: row;
        justify-content: space-around;
      }

      .chat-message form {
        display: flex;
        flex-direction: row;
        width: 95%;
      }

      .chat-message input {
        display: flex;
        flex-direction: row;
        width: 100%;
        height: 48px;
        margin-bottom: 4px;

        background: #ffffff;
        border: 1px solid #e8e9f0;
        box-sizing: border-box;
        border-radius: 4px;
      }

      .send-message {
        display: flex;
        flex-direction: row;
        justify-content: center;
        align-items: center;
        padding: 12px 10px 12px 14px;

        width: 48px;
        height: 48px;

        background: #da1a5b;
        border-radius: 4px;
      }

      .arrow {
        position: static;
        width: 24px;
        height: 24px;
        left: 38px;
        top: 12px;

        transform: rotate(90deg);
      }

      .arrow path {
        fill: #fff;
      }
    `,
  ],
})
export class ChatComponent implements OnInit, OnDestroy {
  // メッセージ
  messageunSubscribe!: () => void;
  // メッセージフォーム定義
  form = new FormGroup({
    message: new FormControl('', { nonNullable: true }),
  });
  // ユーザー情報ストリーム
  user$ = this.authService.user$;
  // チャットメッセージストリーム
  messages$ = this.chatService.messages$;

  constructor(
    // AuthService 定義
    private authService: AuthService,
    // ChatService 定義
    private chatService: ChatService
  ) {}

  ngOnInit() {
    this.chatService.loadMessages();
    this.messageunSubscribe = this.chatService.listenToMessages();
  }

  ngOnDestroy() {
    this.messageunSubscribe();
  }

  // チャットメッセージ送信
  sendMessage() {
    const message = this.form.controls.message.value;

    this.chatService
      .sendMessage(message)
      .pipe(
        tap(() => {
          this.form.reset();
        })
      )
      .subscribe();
  }

  // ログアウト
  async logout() {
    await this.authService.logout();
  }
}

リアルタイム イベントへの接続

  • Appwrite システムで発生するほぼすべてのイベントからリアルタイムの処理を提供します。
    • データベースの insert
    • データベースの update
    • データベースの idelete
  • これらのイベントは、WebSocket を使用して実行されます。
  • リアルタイムにメッセージを更新するのは、ChatService の listenToMessages のメッセージコレクションを購読します
  • 該当処理抜粋(src/chat.service.ts)

export class ChatService {


  // メッセージ受信待機
  listenToMessages() {
    return this.appwriteAPI.database.client.subscribe(
      `databases.${this.appwriteEnvironment.databaseId}.collections.${this.appwriteEnvironment.chatCollectionId}.documents`,
      (res: RealtimeResponseEvent<Message>) => {
        if (
          res.events.includes(
            `databases.${this.appwriteEnvironment.databaseId}.collections.messages.documents.*.create`
          )
        ) {
          const messages: Message[] = [...this._messages$.value, res.payload];

          this._messages$.next(messages);

          setTimeout(() => {
            document.getElementById(`${res.payload.$id}`)?.scrollIntoView();
          });
        }
      }
    );
  }
}

  • SPA フレーム各種対応(Angular, React, Vue.js, Svelte アルファベット順)
  • モバイルアプリ対応(Android, Apple, Flutter)
  • CLI が充実
  • サーバー環境の充実
  • SDK も標準装備
  • ひと手間かければ、AWS や Firebase へのデプロイも可能だと思います。
  • BaaS(Backend as a Service) プラットフォームの appwrite を活用する事によりPoC(Proof of Concept)がより容易に出来ると思います。
  • また Web アプリケーション以外でもモバイルアプリやサーバーサイドが用意に準備できるのでテーマに合った開発も可能です。
  • データベースの構築や認証設定の容易さもご理解いただけたと思います。
  • サンプルコードについて
SNS

よろしくお願い申し上げます。