Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

管理画面にGraphQL + Flutter Webを採用してみた

アドベントカレンダーの担当日を早めにしてさっさと終わらせてしまえば気持ちが楽だと思ったのに前日になっても書き終わっていないのは一体誰でしょう?そう、私(ふそやん@azihsoyn)です。

こちらの記事は Gunosy Advent Calendar 2020 の2日目の記事です。 昨日の記事はかとうさんの 長年稼働しているサービスの全体感をすばやく把握するには でした。

さて、先日プレスリリースでも発表されましたが、グノシーにラジオコーナーが出来ました。

現在ラジオコーナーにはオリジナル番組と、他社の提供するPodcastの2つのコンテンツがあるのですが、オリジナルコンテンツを入稿するための管理画面をFlutter Webで作ったのでその知見をまとめたいと思います。

管理画面の主な機能は、

  • コンテンツのCRUD(GraphQL)
    • 番組管理(CRUD)
    • 番組に紐付くエピソード管理(CRUD)
  • 音源アップロード
  • ユーザー認証
  • おすすめ番組編集機能

こんな感じです。

今回は触れませんが、管理画面のバックエンドはRustのGraphQLサーバーです。

GraphQLについて

APIサーバーからschemaをダウンロードするのはnodeのツールを使っています。(Dartのツールが見つからなかったため)

$ npx get-graphql-schema '<graphql api endpoint>/graphql' > graphql/schema.graphql

schemaファイルからDartのコードを生成するのにはこちらを使っています。

schemaファイルとgraphqlファイルを用意してbuild_runnerを実行すると対応するコードを生成してくれます。

$ flutter packages pub run build_runner build

1点はまりどころとして、1ファイル1queryじゃないとうまく生成できないらしく、queryを追加する度にbuild.yamlを修正しないと行けないのが手間でした。

targets:
  $default:
    sources:
      - lib/**
      - graphql/**
    builders:
      artemis:
        options:
          fragments_glob: graphql/**.fragment.graphql
          schema_mapping:
            - schema: graphql/schema.graphql
              queries_glob: graphql/programs.query.graphql
              output: lib/generated/programs_query.dart
            ~~~~~~~~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~~~~~
            - schema: graphql/schema.graphql
              queries_glob: graphql/delete_pickup_episode.mutate.graphql
              output: lib/generated/delete_pickup_episode_mutate.dart

Flutter + GraphQLについてはこちらの記事がとても参考になりました。

音源アップロードについて

過去に動画コンテンツをアップロードする管理画面を作った際に、APIサーバーを経由して大きいファイルをアップロードすると効率が悪いことが分かっていたため、今回はAPIサーバーを介さずにブラウザから直接S3に音源をアップロードする構成を取りました。

また、入稿する人に再生時間を毎回入力してもらう手間を省きたかったので、音源アップロードする際に再生時間を自動で登録出来るようにしました。 そのために音源ファイルを扱う必要がありました。

いくつか試したのですが、こちらのパッケージでうまく動いています。

ブラウザのファイル選択を使うにはこんな感じのコードになります

// ファイルブラウザ処理
  startWebFilePicker(BuildContext context) async {
    html.InputElement uploadInput = html.FileUploadInputElement();
    uploadInput.accept = '.m4a';
    uploadInput.multiple = false;
    uploadInput.draggable = true;
    uploadInput.click();

    uploadInput.onChange.listen((e) async {
      final files = uploadInput.files;
      final file = files[0];
      var url = html.Url.createObjectUrl(file);
      final assetsAudioPlayer = AssetsAudioPlayer();

      try {
        await assetsAudioPlayer.open(
          Audio.network(url),
          autoStart: false,
        );
        assetsAudioPlayer.current.listen((playingAudio) {
          context
              .read(episodeFormProvider(draft))
              .setDuration(playingAudio.audio.duration);
        });
      } catch (t) {
        return;
      }
      final reader = new html.FileReader();
      reader.onLoadEnd.listen((e) async {
        final encoded = reader.result as String;
        final stripped =
            encoded.replaceFirst(RegExp(r'data:audio/[^;]+;base64,'), '');
        context
            .read(episodeFormProvider(draft))
            .setBytes(base64.decode(stripped));
      });
      reader.readAsDataUrl(file);
    });
  }

認証について

ユーザー管理はOneLoginに任せてしまってCognitoでS3のPut権限を付与しています。

  Future<String> upload(int programId, String mime, Uint8List bytes) async {
    final configs = await _read(configurationsProvider.future);

    final _userPool =
        CognitoUserPool(configs.cognitoUserPoolId, configs.cognitoClientId);
    final _credentials =
        CognitoCredentials(configs.cognitoIdentityPoolId, _userPool);

    // oneloginのid_tokenがsessionStorageに入ってる
    final token = html.window.sessionStorage['id_token'];
    await _credentials.getAwsCredentials(token);

    final _region = configs.s3AudioContentUploadRegion;
    final credential = AwsClientCredentials(
        accessKey: _credentials.accessKeyId,
        secretKey: _credentials.secretAccessKey,
        sessionToken: _credentials.sessionToken);
    String filename = Ulid().toString() + '.' + extensionFromMime(mime);
    final service = S3(region: _region, credentials: credential);
    final bucket = configs.s3AudioContentUploadBucket;
    final prefix = 'audio_contents/';
    try {
      var res = await service.putObject(
        bucket: bucket,
        key: "${prefix}/${filename}",
        contentType: mime,
        body: bytes,
      );
      return 'https://${bucket}/${prefix}/${filename}';
    } catch (e) {
      print("put error : ${e}");
    }
  }

S3のclientはこちらを使っています。 cognito_identityはこちらを使っています。

デプロイについて

認証周りの設定をEnvoyに任せたかったのでk8s上にホストしています。

buildして静的ファイルを生成してからdenoでserveしています。denoを使っているのは趣味なのでnodeでもflutter runで動かしてもいいと思います。

参考までにDockerfileを置いておきます

# syntax=docker/dockerfile:experimental

## build flutter web
FROM plugfox/flutter:beta as flutter-build

RUN mkdir -p /flutter/src

WORKDIR /flutter/src

COPY . /flutter/src
RUN flutter pub get
ARG ENV
ENV ENV $ENV
ENV Dart2jsOptimization O1
RUN flutter build web --dart-define ENV=$ENV --dart-define FLUTTER_WEB_USE_EXPERIMENTAL_CANVAS_TEXT=true

# runner
FROM hayd/alpine-deno:1.1.0

COPY --from=flutter-build /flutter/src/build/web /
COPY --from=flutter-build /flutter/src/main.ts /main.ts

RUN deno cache main.ts

CMD ["run", "--allow-net", "--allow-read", "main.ts"]

おすすめ番組編集機能について

こちらのパッケージを使ってDrag&Dropで並び替えて編集できます。 せっかくFlutterを使うので直感的に操作できるUIにしようと個人的にこだわりました。

おわりに

Flutter Webは発表されてからものすごい勢いで進歩していて、社内用の管理画面であれば十分実用に足ると思いました。 サンプルも充実してきており、ここ を触ってみてやりたいことが出来そうか検討してみるのがいいと思います。

ただベータ版ではあるので用途に応じて採用するのがよさそうです。

例えばSEOを重視するようなサービスへの採用にはまだ早いかもしれません。(issueは起票されているのでいずれは対応されるかもしれませんが)

Flutter Webで使えるパッケージ紹介記事になってしまいましたが、実際開発するにあたってWeb対応されてるものを試しながら選定する必要があったのでお役に立てたら幸いです。(書き切れなかったこともあるので気軽に質問してください)


明日は jkatagi さんの AWS Gamedayに参加した話 らしいです。お楽しみに!