ネイティブアプリ風ウェブアプリを Ionic で作ってみる(完全入門編)その4

ネイティブアプリ風ウェブアプリを Ionic で作ってみる(完全入門編)その4

ここで作成しているアプリは、

こちら

で公開しています。

また、ソースコードは、こちらで公開しています。

Amplify で ToDo のメモ、タイトルを全文検索する

少ない ToDo で微妙ですが、メモ、タイトル含めた全文検索をしたいと思います。

Amplify では非常に簡単に検索機能を追加できます。どのくらい簡単かというと GraphQL のスキーマに @searchable をつけるだけです。UI 側でも検索ツールバーを追加すれば良いと言う事になるおので比較的難易度が低い実装です。

type Todo @model @searchable @auth(rules: [{ allow: owner }]){
  id: ID!
  title: String!
  due: AWSDate
  memo: String
  done: Boolean
  userId: String!
}

あとは、amplify push ですね。

amplify push

これは数分かかります。

@searchable について

その間に何をしているか説明しましょう。まず、通常の API は、AWS 上の DynamoDB というデータベースに保存されます。DynamoDB は、NoSQL といって、データを構造化されていない JSON のような形式で保存しています。書き込みや、ID による検索は超高速なのですが、値による検索をすることができません。できないというか、総当たり戦法になるので非常に効率が悪いです。また、データの件数も取得することができません。nextToken というカーソルベースに対してここから何件という取得の仕方はできるのですが、件数はわからないのです。

これは特定のユースケースでは致命傷となる欠点ですが、それを補うのが、Elasticsearch です。@searchable というディレクティブをつけて、amplify push すると Elasticsearch が用意され、@searchable がついたデータが更新されると、DynamoDB の Stream という機能を使って Elasticsearch に同期されます。Elasticsearch では、複雑な検索、集計ができるので全文検索はこちらでやるという棲み分けができるのです。

この機能にも幾らかの制限があります

  • DataStore と互換性がない
  • 既存のデータは、インデックスされない(マイグレーションするか初めからつけておくかが必要)
  • @connection ディレクティブと互換性がない
  • t2.micro で使えない

DataStore というのは、オフラインでもアプリを利用することができてオンラインになったら辻褄が合うようにデータベースと同期してくれる機能なのですが、これと互換性がないということは、逆にいうと、DataStore を使うと検索機能をつけるのが大変になりますよっていうことですね。今回は関係ないです。

二番目の既存のデータがインデックスされないことも、手動で更新などすれば良いので今回は問題ないでしょう。

三番目の @connection ディレクティブと互換性がないというのは、今回は関係ないですが、知っておいた方が良いと思うので説明します。例えば、ToDo モデルが別のモデルの参照をもつ場合を考えてみましょう。

type Todo @model @searchable @auth(rules: [{ allow: owner }]){
  id: ID!
  title: String!
  due: AWSDate
  memo: String
  done: Boolean
  userId: String!
  user: User @connection(fileds: "userId")
}

この時に、@connection のフィールド、ここで言うと例えばユーザーの名前などを検索対象にできないということのようです。GitHub

どのフィールドの情報を使って検索するか、どういうモデルにするかは初めに割としっかり考えておいた方が良さそうですね。

自動生成されたコードを確認

srcフォルダ内のgraphql フォルダに入っている queries.graphql にSeachTodos というクエリが加わりました。

query SearchTodos(
  $filter: SearchableTodoFilterInput
  $sort: SearchableTodoSortInput
  $limit: Int
  $nextToken: String
  $from: Int
) {
  searchTodos(
    filter: $filter
    sort: $sort
    limit: $limit
    nextToken: $nextToken
    from: $from
  ) {
    items {
      id
      title
      due
      memo
      done
      userId
      createdAt
      updatedAt
      owner
    }
    nextToken
    total
  }
}

こちらの戻り値には、nextToken に加えて total があるので件数がわかります。

API.service.ts の方も SearchTodos が追加されています。

  async SearchTodos(
    filter?: SearchableTodoFilterInput,
    sort?: SearchableTodoSortInput,
    limit?: number,
    nextToken?: string,
    from?: number
  ): Promise<SearchTodosQuery> {
    const statement = `query SearchTodos($filter: SearchableTodoFilterInput, $sort: SearchableTodoSortInput, $limit: Int, $nextToken: String, $from: Int) {
        searchTodos(filter: $filter, sort: $sort, limit: $limit, nextToken: $nextToken, from: $from) {
          __typename
          items {
            __typename
            id
            title
            due
            memo
            done
            userId
            createdAt
            updatedAt
            owner
          }
          nextToken
          total
        }
      }`;
    const gqlAPIServiceArguments: any = {};
    if (filter) {
      gqlAPIServiceArguments.filter = filter;
    }
    if (sort) {
      gqlAPIServiceArguments.sort = sort;
    }
    if (limit) {
      gqlAPIServiceArguments.limit = limit;
    }
    if (nextToken) {
      gqlAPIServiceArguments.nextToken = nextToken;
    }
    if (from) {
      gqlAPIServiceArguments.from = from;
    }
    const response = (await API.graphql(
      graphqlOperation(statement, gqlAPIServiceArguments)
    )) as any;
    return <SearchTodosQuery>response.data.searchTodos;
  }

SearchTodo の使い方

まず、filter と言う引数の型が、SearchableTodoFilterInput になっていますので、これを見ます。

export type SearchableTodoFilterInput = {
  id?: SearchableIDFilterInput | null;
  title?: SearchableStringFilterInput | null;
  due?: SearchableStringFilterInput | null;
  memo?: SearchableStringFilterInput | null;
  done?: SearchableBooleanFilterInput | null;
  userId?: SearchableStringFilterInput | null;
  and?: Array<SearchableTodoFilterInput | null> | null;
  or?: Array<SearchableTodoFilterInput | null> | null;
  not?: SearchableTodoFilterInput | null;
};

例えば、文字での検索なので、タイトルとメモだけのことを考えましょう。

export type SearchableStringFilterInput = {
  ne?: string | null;
  gt?: string | null;
  lt?: string | null;
  gte?: string | null;
  lte?: string | null;
  eq?: string | null;
  match?: string | null;
  matchPhrase?: string | null;
  matchPhrasePrefix?: string | null;
  multiMatch?: string | null;
  exists?: boolean | null;
  wildcard?: string | null;
  regexp?: string | null;
  range?: Array<string | null> | null;
};

上の方の、略語は、ne (not equal), gt (greater than), lt (less than), gte (greater than or equal), lte (less than or equal) ですね。今回は数値ではないので関係ないでしょう。今回は、match だけで良さそうですね。

なので、SearchableTodoFilterInput は、

{
  or: [
    { 
      title:  {  match: query  },
    },
    {
      memo: { match: query },
    }
  ]
}

で良さそうです。

検索バーをつける

ion-searchbar でつけます。

  <ion-toolbar>
    <ion-searchbar (ionChange)="onSearchChanged($event)" showCancelButton="focus"></ion-searchbar>
  </ion-toolbar>

イベントを拾いましょう。

  onSearchChanged($event): void {
    const query = $event.target.value;
    console.log(query);
  }

とりあえず、フロントだけの検索を実装しましょう(これで十分説はありますがw)。filteredTodo$ と言うものを表示用に使います。

  filteredTodos$: Observable<ToDo[]> = this.todos$;
  ....

  onSearchChanged($event): void {
    const query = $event.target.value;
    console.log(query);
    this.filteredTodos$ = this.todos$.pipe(
      map(todos => {
        return todos.filter(todo => {
          return todo.title?.match(query) || todo.memo?.match(query)
        })
      })
    )
  }

HTML 側でも filteredTodo$ を参照させます。

    <ion-item-sliding *ngFor="let todo of (filteredTodos$ | async)">

やってみましょう。

できていますね。

Search API を使って検索する

ほとんど意味ないですが、やってみましょう。

  • やたら検索APIを要求しても嫌なのでボタンで検索するように変更
  • AmplifyTodoService で検索機能を実装する
  • Slice と Effect を作成し、検索結果表示モードとを切り替えるようにする
  • TodoFacade で、アクションを呼び出すようにする

この手順でいきましょう。

検索ボタンを設置

toolbar に ion-buttons と言うものをつければ綺麗にできます。

  <ion-toolbar>
    <ion-buttons slot="end" (click)="onSearchChanged()">
      <ion-icon slot="start" name="refresh" color="primary"></ion-icon>
    </ion-buttons>
    <ion-searchbar showCancelButton="focus" (ionCancel)="onSearchCanceled($event)"></ion-searchbar>
  </ion-toolbar>

Cancelボタンのイベントもつけておきましょう。todo.page.ts の方でもイベントを拾っておきましょう。


  onSearchCanceled($event): void {
    console.log($event);
  }

AmplifyTodoService に検索機能を実装

こちらもシンプルですね。query を拾って先ほど検討した実装をすれば良いです。


  searchToDo(query: string): Observable<SearchTodosQuery> {
    console.log('search todo', query);
    return from(this.api.SearchTodos(
      {
        or: [
          {
            title: { match: query },
          },
          {
            memo: { match: query },
          }
        ]
      }
    ))
  }

Slice と Effect を修正

Slice に searchTodos と searchTodosSuccess の2つのアクションを加えましょう。

    searchToDos: (state, action) => {
      state.searchToDoLoading = true;
    },
    searchToDosSuccess: (state, action) => {
      state.searchToDoLoading = false;
      state.todos = action.payload;
    },

これでいけますね。export も忘れずにやっておいてください。

Effects にも追加しましょう。

  searchTodos$ = createEffect(
    () => this.actions$.pipe(
      ofType(fromTodo.searchToDos),
      switchMap(action => {
        return this.todoService.searchToDo(action.payload).pipe(
          map((todos) => {
            const todoList = todos.items.map(todoItem => {
              return new ToDo({ ...todoItem, ...{ due: null, stringDate: todoItem.due } })
            })
            return fromTodo.searchToDosSuccess(todoList);
          })
        )
      })
    )
  );

TodoFacade でアクションを呼び出す

バシバシいきましょう。

  searchToDo(query: string): void {
    this.store.dispatch(todoSlice.searchToDos(query))
  }
}

これだけでOKですね。ついでに uiService にも状態を追加します。

  searchToDoLoading$ = this.store.select(
    createSelector(todoSlice.selectFeature, (state) => state.searchToDoLoading)
  )

...

  loading$ = merge(
    this.fetchLoading$,
    this.addToDoLoading$,
    this.updateToDoLoading$,
    this.deleteToDoLoading$,
    this.authLoading$,
    this.searchToDoLoading$,
  )

画面の方を設定しましょう。

  onSearchChanged(): void {
    const query = this.query;
    this.todoService.searchToDo(query);    
  }

  onSearchCanceled($event): void {
    console.log($event);
    this.todoService.fetchToDos();
  }

ではやってみましょう。

完璧ですね。

PWA 対応をしてアプリを公開する

アプリを公開しましょう。まず、PWA (Progressive Web App) にします。これにすると、ホーム画面にインストールできるようになります。iOS では、まだプッシュ通知に対応していませんが、Android ではプッシュ通知に対応しているのでアプリのように使うことができます。

Angular CLI から PWA を作成

まずは、Angular CLI をインストールします。

npm install -g @angular/cli

これでOKです。

ng add @angular/pwa

とりあえず、これだけです。src/assets/icons に Icon セットが入ったかと思います。これを変更すればアイコンが変えられます。

このアイコンに変えてみましょう。

これを元に PWA 用のアセットを作成します。

npx pwa-asset-generator icon.png ./assets --icon-only

これをangular のアイコンと差し替えます。

さらにマニフェストも変更しましょう。

{
  "name": "Mytodo",
  "short_name": "Mytodo",
  "theme_color": "#1976d2",
  "background_color": "#fafafa",
  "display": "standalone",
  "scope": "./",
  "start_url": "./",
  "icons": [
    {
      "src": "assets/icons/manifest-icon-192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "maskable any"
    },
    {
      "src": "assets/icons/manifest-icon-512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable any"
    }
  ]
}

index.html に iOS 用のアイコンをセットします。

  <link rel="apple-touch-icon" href="assets/icons/apple-icon-180.png">

favicon も作っておきましょう。

pwa-asset-generator icon.png ./assets --opaque false --icon-only --favicon --type png

アプリケーションをデプロイする

ampify add hosting でやります。今回は、手動デプロイを行います。

amplify add hosting
 ? Select the plugin module to execute Hosting with Amplify Console (Managed hosting with 
 custom domains, Continuous deployment)
 ? Choose a type Manual deployment

アプリケーションをビルドします。

ionic build --prod

しばらくすると、www フォルダにコンパイルされたファイルが生成されます。gitignore しておきましょう。

amplify publish

これは数分かかります。

ここで転けた場合は、amplify configure project でbuild command があっているか確認してください。build コマンドは、ionic build –prod です。

しばらくすると完了してURLが出ますので、そちらにアクセスしてみてください。

https://dev.d24wcf2ponetu0.amplifyapp.com/

できましたね。

Chrome で、インストールボタンもでます。

iOS でもホーム画面に追加できるようになっています。

実際に追加した場合の見え方。

起動した時はアプリのようになっています。

ログイン画面

todo 画面

これで完了です。お疲れ様でした!

Fake Auth サービスも使えるようにする

最後に、FakeAuth や、FakeToDo サービスも使えるようにリファクタリングしておきます。詳細は省略しますが、ソースコードは、こちらで 11-refactor というフォルダの中に公開しております。参照してください。こうしておくと、ローカルだけのサービスと簡単に切り替えられるのでデバッグ時に便利になりますね。

弊社ではエンジニア、積極採用中!ただ、この技術は使ってませんw

弊社では、エンジニアを大募集しています。自動化DXの未来にチャレンジしたい方はぜひ応募してください!それではまた。

https://www.wantedly.com/projects/558195