every Tech Blog

株式会社エブリーのTech Blogです。

FlutterでiOS、AndroidアプリをWebで動かせるようにする

DELISH KITCHEN 開発部で小売向き合いでFlutterのアプリ開発をしている野口です。

本記事では、弊社で開発しているFlutterのアプリをFlutter Webでリリースできるかどうかの調査を行った時の知見についてお話しします。

FlutterアプリをWebで動かすとは

Flutterはマルチプラットフォーム開発できるので、Android / iOS / Web / Windows / macOS / Linuxで同じソースコードで開発できます。なので、iOS、Android用に作成したアプリでもリリースできます。 一般的なWebサイトを作るときは、HTMLやCSS、JavaScriptを使用しますが、FlutterはiOS、Androidと同じ見た目になるように、HTML、CSS、Canvasなどを使用して描画してくれます。また、FlutterはDartという言語で書かれていますが、それをJavaScriptに変換してくれています。

ただ、パッケージを使用した場合、モバイル特有の機能(ネイティブコードでないと実現できないもの)などDartで書かれてない可能性があるため、パッケージの公式ドキュメントを見てWebに対応しているか確認する必要があります。ここにWebの記載があればWeb対応しているパッケージだと判断できます。

https://pub.dev/packages/flutter_riverpod

riverpod

FlutterアプリをWebで動かすにあたっての課題

まとめると以下のような課題がありました。

  • パッケージがWebに対応しているか
  • Platform.isAndroid Platform.isIOSの分岐エラー

具体的な対応

パッケージがWebに対応しているか

対応していないもの

そもそもWebに対応してないパッケージがあるので、その場合は代替を探すか、Javascriptで書くか、Webではその機能を諦めるかをしないといけません。 今回は以下のパッケージが使用できませんでした。 - firebase_crashlytics(そもそもWebはクラッシュしないのでいらない) - path_provider - flutter_html - adjust_sdk - flutter_appauth - dart_jsonwebtoken

対応していたが、途中で動かなくなったもの

isar

isarはv3ではエラーが出て動かなくなっていました。

エラー内容

Error: The integer literal 288085404374050446 can't be represented exactly in JavaScript. Try changing the literal to something that can be represented in JavaScript. In JavaScript 288085404374050432 is the nearest value that can be represented exactly. id: 288085404374050446,

https://pub.dev/packages/isar

issueも出ており、v4では動くようになっているようですが、公式ドキュメントにISAR V4 IS NOT READY FOR PRODUCTION USEとあるので本番環境で使用するのは避けたほうが良さそうです。

https://pub.dev/documentation/isar/4.0.0-dev.14/

対応としては、 isarはローカルデータベースを扱うためのパッケージなので代替になるパッケージに書き換えるが良いかと思います。

v4がstableになるのが待てるのであれば待った方がいいですが、、、v4のPrereleaseが出てから時間が経っており、いつstableになるかわからない状態なので、一旦考えない方針にしています。

バージョンを上げれば対応されるもの

flutter_secure_storage

エラー内容

Unsupported operation: Platform._operatingSystem

使用しているバージョンではWebが対応していないため、5.0.0に上げれば解決します。

Platform.isAndroid Platform.isIOSの分岐エラー

エラー内容

Unsupported operation: Platform._operatingSystem

Webで実行時にPlatform.isAndroid Platform.isIOSがあると起こるようです。 この記事のように、Webの分岐を入れるか、universal_platform(https://pub.dev/packages/universal_platform)を使用することで対応できるかと思います。

https://zenn.dev/ryo_ryukalice/articles/140a64f894afad

Flutte Webを採用して開発運用を行う上でのビジネス上のリスク(考慮事項)

ビジネス上のリスクは以下が挙げられるかなと思います。アプリの複雑度によってリスクの重みは変わるかもしれないですが、これらが許容できればいい選択肢かなと思います。

  1. やりたいことを実現するためのWebに対応しているパッケージがない
  2. isarのようにWebに対応していたパッケージが、更新されなくなりWebが動かなくなる
  3. 2が原因でflutterのバージョンを上げづらくなる

iOS、AndroidアプリをFlutter Webで動かす

iOS、Androidアプリ

Flutter Webアプリ

Flutter Webを動かした結果、画像のようになりました。 見た目としてはiOS、Androidアプリがブラウザのサイズに合わせてそのまま大きくなっています。 このままでも見た目はそんなに悪くないかなと思いますが、商品情報が大きすぎるなどの場合はレスポンシブ対応か、モバイルのサイズに統一するなどすると良くなると思います。 動作が重くなる様子はなかったのでリリースはできるかなと思いました。

まとめ

Flutter Webで開発をする際の主な考慮点は使用するパッケージがWebに対応しているかどうかということがわかりました。 ただ、Webに対応していても動かなくリスクがあるので、Webだけは使えない機能が出る可能性もありそうですね。

個人的にはシンプルなアプリであれば基本的には動きそうなので用途によっては良い選択なのではと思いました。

認証・課金の共通基盤

はじめに

エブリーでソフトウェアエンジニアをしている本丸です。
先日、弊社からヘルスケアアプリ「ヘルシカ」がリリースされたのはご存知でしょうか?ヘルシカは弊社のサービスであるDELISH KITCHENのヘルスケア機能を切り出したサービスなのですが、ヘルシカの裏側で認証・課金の共通基盤が動いています。
今回はこの認証・課金の共通基盤(社内でDAPと呼んでいるため、以降はDAPと表記します)についてお話しできればと思います。なお、実装の詳細には触れず概要の説明に留める予定です。

システムの概要

DAPとは

DAPとは、認証・課金の共通基盤で、IdP(IDプロバイダー: ユーザーIDを保存および検証するサービス)としての役割と、課金を管理する役割を持っています。
DAPという名称は、一般的に使われるものではなくいわゆる造語なのですが、社内やチーム内で認識を合わせるために命名されたという経緯があります。
DAPの目的は、複数サービスでのユーザーの管理を一元化することです。

下図はDAPとそれに関わるものを表した概要図です。
DAPではSNSを用いた認証をサポートしているので、LINEやAppleといった外部のプラットフォームを利用します。以降は、DAP内の認証サーバーのことをInternal IdP、認証に利用する外部のプラットフォームのことをExternal IdPとします。
矢印は、依存の方向を示していてPaymentはInternal IdPに依存しているという関係になっています。DAPはExternal IdPや外部の課金プラットフォームに依存しており、弊社のサービスがDAPを利用するという形になっています。

認証サーバとしての役割

認証サーバーとしての役割は、ユーザーがどのアカウントと紐づくのかの認証を行うというのが主な役割になります。サインアップの時は図のようなフローになるのですが、全て説明すると長くなってしまうので要点だけお話しします。

Internal IdPとExternal IdPの間の認証情報

Internal IdPはExternal IdPに認証を委譲しています。Internal IdPはExternal IdPからIDトークンを受け取るのですが、このIDトークンの中にIDトークンの発行者や一意の識別子などが含まれており、それをもとにどのユーザーなのかの判断を行います。

Internal IdPとApplication ServerとClientの間の認証情報

ClientからApplication ServerのAPIを呼び出す時にはAccess Tokenを認証済みかどうかの判定に利用します。このAccess TokenはInternal IdPで発行しています。Application ServerはClientからAccess Tokenを受け取った時に、Internal IdPを通して認証を行い、認証が成功した場合に後続の処理を行うことになります。

Web View

図の中で、web viewに言及している箇所があるのですが、これはClientがスマホのアプリの時の挙動を示したものです。ClientとExternal IdPの間で直接認証する場合は、External IdPが用意してくれているSDKを利用した方が便利ではあるのですが、Internal IdPを経由させる目的でweb viewから認証を行うようにしています。

課金サーバとしての役割

課金サーバとしての役割は主に2つです。

1つ目はユーザーが商品を購入した際にappleから受け取ったレシートを検証して、商品の有効性を確かめることです。

2つ目はレシートとユーザー状態の管理・更新です。DAPではappleからレシートの情報が更新された時に通知を受け取り、それをトリガーとしてユーザーの状態の更新を行なっています。

レシートとユーザー状態の管理については、弊社ブログの過去記事にもありますのでよければご覧ください。
https://tech.every.tv/entry/2022/04/07/170000

RFCに則った実装

少し、話は逸れるのですがDAPの中のInternal IdPに関してはRFCやOIDCのドキュメントに則った実装が基本方針になっています。
社内用にカスタマイズされたドキュメントではないので、一見とっつきにくくもあるのですが、Internal IdPに関しては下記の理由などでドキュメントに則った方が良いという判断になったようです。 - IdPなので社内特有のロジックが入り込みにくい - 社内ドキュメントよりドキュメントのメンテナンスが維持されやすい - セキュリティ的な要件も満たせる

動作確認の困難さ

要件として、従来のDAPの仕様に則っていないサービスと新規のDAPの仕様に則っているサービスでユーザーのアカウント・課金状態を紐づける必要がありました。
この連携パターンが、従来のサービスでSNS連携されているか、新規サービスでSNS連携されているかなどに加えて課金状態の確認まで必要だったため、かなり複雑に感じました。
動作確認の段階でどのようなパターンがあるか洗い出してテストをしたのですが、動作確認の段階で考慮漏れなどが見つかり修正に追われるといったこともありました。複雑なシステムを作るときは想定されるパターンをあらかじめ洗い出してから開発すべきだったかなというのが反省です。

まとめ

記事にしたこと以外でもリリースまでに色々と大変なこともあったのですが、なんとか致命的なバグはなく動いているようなので一安心といったところです。 複雑なシステムなので概要を話すだけの形にはなってしまいましたが、認証・課金基盤でどのようなことをやっているのかの導入になれば幸いです。

最後に宣伝になりますが、このDAPを裏で利用しているヘルシカというサービスがリリースしたのでよければ使ってみてください。

参考資料

エブリーで新卒2年目を迎えて

エブリーで新卒2年目を迎えて

目次

はじめに

こんにちは。 トモニテ開発部ソフトウェアエンジニア兼、CTO室Dev Enableグループのktanonymousです。
4月1日をもって新卒入社してから1年が経ちました。 そこで、今回の記事では、これまでの振り返りと2年目を迎えた今感じていることについて書きたいと思います(文字ばかりですがご容赦ください)。

1年目を振り返る

入社前について

1年目を振り返る前に、入社前の経緯について少しだけ触れておこうと思います。
大学では工学部の電気情報物理工学科に所属し、情報分野だけでなく物理学や電磁気学なども学んでいました。 また、大学院ではマルチエージェントシミュレーション1に関する研究を行っていました。 講義や研究を通してAIやコンピュータの基礎を学んでいく中で、情報分野/エンジニアリングに興味を持つようになりました。
しかし、実際にものづくりをした経験は無く、エンジニアリングに関しては専門的な知識がほとんどありませんでした。 そのため、大学院時代にはプログラミングの基礎を学ぶために、Pythonを中心に学習を始めました。 また、A Tour of Goも少しだけやりました。
そのほかにも、友人と一緒に簡単なwebアプリを作ったり、ハッカソン型のインターンに参加したりして、 少しでも開発経験を多く積めるよう努力をしました。 そして、就職活動をする中で、イベントの中でエブリーと出会い入社まで至りました。

実際に入社してから

入社してからは、1週間の新卒研修を受けた後、実際の業務に携わることになりました。 OJTですぐに実際の業務に携わり、入社直後に事業貢献できるスピード感はベンチャー企業ならではだと思います。
トモニテではバックエンドにはGo、フロントエンドにはJavaScript/TypeScript(フレームワークはReact/Next.js)を使って開発をしています。 初めのうちは、既に実装されているAPIの改修やコンポーネントの表示ロジックの改修など、バックエンド/フロントエンドどちらかのみのタスクを担当していました。 徐々にタスクの幅も広がり、新規APIを作成したり画面を作成したりすることも増えました。 それからは、APIと画面を実装して疎通させたりLPを1から作成したりもしました。
また、サービスのリブランディングという大きなプロジェクトも経験することができました。 リブランディングプロジェクトを通じてサービスの目指す方向性を改めて考えることができ、 サービスに込めた想いをチームの一員として実現していくことに対する責任感も強くなりました。

社内では、事業向き合いの業務がメインでありつつ、積極的な技術的挑戦の機会を提供するための施策も用意されています。 11月に社内で行われた挑戦weekでは、ChatGPTを利用した社内ChatAppのテンプレート機能の実装にも挑戦しました。

実務未経験から約1年を経て、今では、新機能の開発および開発のリードを担当させていただけるようにもなりました。 自分が開発をリードすることになるため、新機能の開発に必要となるAPIやDB設計なども担当し、 モバイルアプリ開発側との連携やプロダクトマネージャーとの仕様・工数の調整なども行っています。 今開発している新機能では複雑なロジック・仕様も含まれていて、実装は簡単ではありませんが、 サービスを大きく成長させる機能にするために、チーム全体で協調しつつ開発に取り組んでいます。 この新機能も近いうちにリリースされる予定ですので、リリースされたら是非使い倒していただけると幸いです。 責任範囲の広いタスクは大変ではありますが、難しいタスクに挑戦できることや施策をリードする側として動くことで プロダクトに対する責任感も高まり、技術的な視点だけでなくマネジメント・ビジネス的な視点を持つことの重要性も感じることができました。
CI/CDやインフラ周りの知識についてもまだまだ浅識なので、隙を見て地道なキャッチアップを続けています。

また、冒頭でも書いている通り、最近はDev Enableグループも兼任させていただいています。 Dev Enableグループでは、開発本部を横断し、組織の活性化・成長環境の提供・発信・広報の強化・採用など、さまざまな課題解決の推進を目指します。 詳しくは以下の記事をご覧ください。

1年目は実務経験の無い状態からのスタートでしたが、 自分のスキルアップ・マインドの醸成のために積極的にチャレンジし、新しい技術や知識を吸収することができました。 任せてもらえる領域も広がり、自身の成長を大きく感じられる1年でした。

2年目に突入して

4月1日には新卒社員の入社式も行われました。 後輩を迎え、改めて自分が2年目を迎えたことを実感しました。
4月に入り、新卒社員のオンボーディングプロジェクトにも携わっています。 また、今年度から新卒エンジニア向けの研修プログラムも予定されています。 これは、Dev Enableグループが主導で行っているプロジェクトの一つです。

サービス開発を通じての事業貢献だけではなく、組織の活性化やエンジニアの成長など会社全体への貢献もできるようになり、 エブリーの一員としての責任感もより一層強くなってきました。
今後も、自分のスキルアップはもちろん、トモニテ/エブリーの成長に貢献できるよう積極的にチャレンジしていきたいと思います。

まとめ

今回の記事では、4月を迎えた今だからこそ書ける内容だと思い、新卒1年目を迎えての振り返りと2年目を迎えて感じたことについて書いてみました。
就職活動中、ハッカソン型のインターンシップに参加することはありましたが、実務経験は一切ない状態での入社だったので経験豊富な同期や先輩方に負けないよう必死でした。
この記事を書くことで、自分のこれまでの挑戦を振り返ることやこれからについて改めて考えることができる良い機会になりました。
まだまだ未熟で、今でも日々のキャッチアップや新しい技術の習得に励んでいますが、1年目を振り返ると、非常に大きく成長できた1年だったと感じています。 2年目も、サポートしてくれる周囲の人への感謝を忘れずに、1年目以上に成長できるようにチャレンジしていきたいと思います。
最後まで読んでいただき、ありがとうございました。

GASを使ってRedashからデータ抽出して、G Spreadsheetを作成する

はじめまして、データストラテジストのoyabuです。 RedashからCSVでデータをエクスポートして、GoogleDriveに保存、更にCSVをSpreadsheet化してようやく可視化の準備が整うの、めんどくさいですよね。それらを自動化するGASを作ったので、書きます

注意点

極限までサボりたかったのでChatGPTに聞いてツギハギして動けばヨシの精神で作りました。出来上がったものをみて、コードの整然さやエラーハンドリングについて、思うところは多々ありますがそのままにしています。社内用に展開しているものは複数クエリに対応しているのですが、1->nになると本筋と関係のない話題が増えるので今回はデータを抽出するクエリが1つに限定されたGASのコードを考えることにします

課題

まずそもそもなんでこれやったかです。以下が理由です

  • RedashのQuery API はSpreadhsheet上でIMPORTDATAできて便利だが、パラメータを使っているクエリのデータは抽出できない
  • 基本CSV->G Spreadsheetに変換したうえで加工することが多いので、施行回数が多くなるとつらい
  • using redashAPI with GASの記事はいくつかみかけるが、ポーリングをsleepなどにまかせていて、重いクエリがそもそも回せない

やったこと

上記問題を解くために、以下を実施しました

  • パラメータつかってるクエリからもAPI使ってデータ抽出できるようにする
  • 時間がかかるクエリはポーリングする(GASのtimeoutである6minに負けない)
  • CSV->スプレッドシート化まで自動化

中でも本記事で触れるのはあんまり情報として見ない(気がする)以下の項目です

  • RedashのUser API Keyの簡単な解説とGASでの使い方
  • GASのトリガーを使ったポーリング

その他についてはよく見るので、詳細については本記事では触れません

RedashのUser API Keyの簡単な解説と使い方

詳しくは公式を参照していただければと思うのですが、めんどくさいです。まずresponseが書いてありません。愚直にrequestして、responseをみる必要があります。つらいです

API

今回やりたいことを実現するうえでの主役はこいつになります /api/queries/<id>/results クエリIDとパラメータを渡してPOSTしたときにキャッシュされた結果があればそれを、なければクエリを実行するエンドポイントです

responseの中にstatusを格納したキーが無く、query_resultキーがあればデータが返ってきた。なければクエリが実行されたので、ポーリングしてデータが返ってくるまで待つ。の判断をしないといけないのでちょっとゾワゾワします

親切にやるならこっちでstatusを判断してObjectとしてラップして返しちゃう関数を作るのがよいと思いますが、今回のコンセプトは極限までサボる。です。心を鬼にしてChatGPTが出したものを正として進めていきます。

余談ですがChatGPTは一瞬で80点までは出してくれるのですが、100点までChatGPTオンリーで詰めるのはちょっとしんどいと思ってます。(百里を往くものは九十を半ばとす。なので、このあたりは自前の実装でも同じことは言えますが、一段抽象化されているのでコントロールが効きづらく、十里がより遠くなる印象)

出来上がり is belowなのですが、ここに関する白眉なコードはこんな感じです

function _getJobId(queryId, param) {
  const apiUrl = `${host}/api/queries/${queryId}/results`;
  const data = {
    parameters: param
  };
  const payload = JSON.stringify(data);
  const options = {
    'method' : 'post',
    'contentType': 'application/json',
    'headers': {
      'Authorization': 'Key ' + apiKey
    },
    'payload' : payload,
    'muteHttpExceptions': true
  };
  let results = UrlFetchApp.fetch(apiUrl, options);
  let isResult = !!JSON.parse(results).query_result;
  console.log(isResult);
  if (isResult) {
    console.log(JSON.parse(results).query_result.data.rows);
    return JSON.parse(results).query_result.data;
  }
  const jobId = JSON.parse(results).job.id;
  return jobId;
}

なんとキャッシュがあれば配列を、なければjobIdを返します。ChatGPTが言うので仕方ないですが結構しんどいです とはいえ、/api/queries/<id>/results の仕様上どこかでこんな感じの処理が必要になってきます(もうちょい考慮してwrapするべきという議論は置いておきます)

GASのトリガーを使ったポーリング

GASのtimeoutが6minなので、sleepで待ってもそもそも重いクエリの実行が無理になってしまいます。 一方でGASはトリガーが結構な量作れたりするので、これを使ってポーリングすると楽になるシーンが多いです。 変数は渡せないので、そこはspreadsheetのシート上に持たせる解き方で頑張ります。(ほんとは実行者のみ編集できるセルとかにしたほうがいいけど、サボります)

例えばこんな感じです。今回は自分を呼んでます

  data = getJobResult(jobId);
  if (!data) {
    ScriptApp.newTrigger('generateRedashFiles').timeBased().after(min * 60 * 1000).create();
    return;
  }

結果のキャッシュがなかったときは、Redash側でクエリが実行されるので、一旦ジョブIDをスプレッドシートに保存しといて、再度自分を呼び出したときに参照するようにします こんな感じでトリガーが作成されます。急いでなかったり、重めのクエリかもなー。というときは30minとかで良さそうな気もします

出来上がり

ChatGPTにいっぱい聞いてツギハギしてちょっとだけ手直しした結果がこれです。とりあえず動きます。使い方は後述します。

const host = '${redashのホスト名}';
const apiKey = '${redah user API Key}';
const urlSheetName = 'URLリスト'; // TODO: edit as each env
const ss = SpreadsheetApp.getActiveSpreadsheet();
const folderId = '${データ保存先のG DriveフォルダID}';
let min = 30;

function generateRedashFiles() {
  let sheet = ss.getSheetByName(urlSheetName);

  let data;
  let url = sheet.getRange(2, 2).getValue();
  let fileName = sheet.getRange(2, 1).getValue();
  let jobId = sheet.getRange(2, 3).getValue();

  if (!jobId || jobId == '') {
    jobId = getJobId(url);
    // jobID or data
    if (jobId instanceof Object) {
      data = jobId;
      json2Csv(fileName, data);
      return;
    } else {
      sheet.getRange(2, 3).setValue(jobId);
    }
  }

  data = getJobResult(jobId);
  if (!data) {
    ScriptApp.newTrigger('generateRedashFiles').timeBased().after(min * 60 * 1000).create();
    return;
  }
  json2Csv(fileName, data);
  importCsvFilesToSpreadsheet();
}

function getJobResult(jobId) {
  const jobStatusUri = `${host}/api/jobs/${jobId}?api_key=${apiKey}`;
  let queryResultId = null;
  const jobStatus = JSON.parse(UrlFetchApp.fetch(jobStatusUri)).job;
  const status = jobStatus.status;
  if (status === 3 || status === 4) {
      queryResultId = jobStatus.query_result_id;
  } else {
    return;
  }
  const jobResultUri = `${host}/api/query_results/${queryResultId}.json?api_key=${apiKey}`;
  results = UrlFetchApp.fetch(jobResultUri);
  return JSON.parse(results).query_result.data;
}

function getJobId(url) {
  let payload = generatePayload(url);
  let queryId = payload[0];
  let param = payload[1];
  let jobId = _getJobId(queryId, param);
  return jobId;
}

function _getJobId(queryId, param) {
  const apiUrl = `${host}/api/queries/${queryId}/results`;
  const data = {
    parameters: param
  };
  const payload = JSON.stringify(data);
  const options = {
    'method' : 'post',
    'contentType': 'application/json',
    'headers': {
      'Authorization': 'Key ' + apiKey
    },
    'payload' : payload,
    'muteHttpExceptions': true
  };
  let results = UrlFetchApp.fetch(apiUrl, options);
  let isResult = !!JSON.parse(results).query_result;
  console.log(isResult);
  if (isResult) {
    console.log(JSON.parse(results).query_result.data.rows);
    return JSON.parse(results).query_result.data;
  }
  const jobId = JSON.parse(results).job.id;
  return jobId;
}

function generatePayload(url) {
  url = url.split('#')[0];
  let match = url.match(/\/queries\/(\d+)\/source/);
  let queryId = match ? match[1] : null;
  console.log(queryId);
  let params = {};
  let queryString = url.split('?')[1];
  const regex = /^\d{4}-\d{2}-\d{2}--\d{4}-\d{2}-\d{2}$/;
  if (queryString) {
    let pairs = queryString.split('&');
    pairs.forEach((pair) => {
      let kv = pair.split('=');
      let key = decodeURIComponent(kv[0]).substring(2);
      let val = decodeURIComponent(kv[1] || '');
      if (regex.test(val)) {
        let dates = val.split('--');
        val = {
          'start': dates[0],
          'end': dates[1]
        }
      }
      params[key] = val;
    });
  }
  console.log(params);
  return [queryId, params];
}

function getFolderId() {
  const folderUrl = ss.getSheetByName(settingSheetName).getRange('B1').getValue();
  console.log(folderUrl);
  const matches = folderUrl.match(/[-\w]{25,}/);
  if (!matches) {
    throw new Error('Invalid folder URL');
  }
  return matches[0];
}

function moveFileToFolder(fileId) {
  const folder = DriveApp.getFolderById(folderId);
  const file = DriveApp.getFileById(fileId);
  file.moveTo(folder);
  console.log(`File "${file.getName()}" has been moved to folder "${folder.getName()}"`);
}

function importCsvFilesToSpreadsheet() {
  var folder = DriveApp.getFolderById(folderId);
  var csvFiles = folder.getFilesByType(MimeType.CSV);

  // 新しいスプレッドシートを作成し、特定のフォルダに移動
  var spreadsheet = SpreadsheetApp.create('summary');
  var spreadsheetFile = DriveApp.getFileById(spreadsheet.getId());
  folder.addFile(spreadsheetFile);
  DriveApp.getRootFolder().removeFile(spreadsheetFile);

  var firstSheet = true;

  while (csvFiles.hasNext()) {
    var file = csvFiles.next();
    var fileName = file.getName();
    var csvData = Utilities.parseCsv(file.getBlob().getDataAsString());

    if (firstSheet) {
      // 最初のCSVファイルの場合、既存のシートを使用
      var sheet = spreadsheet.getSheets()[0];
      sheet.setName(fileName);
      firstSheet = false;
    } else {
      // 2つ目以降のCSVファイルの場合、新しいシートを作成
      var sheet = spreadsheet.insertSheet(fileName);
    }

    // CSVデータをシートに書き込む
    var range = sheet.getRange(1, 1, csvData.length, csvData[0].length);
    range.setValues(csvData);
  }

  // 最後にスプレッドシートを開く
  SpreadsheetApp.getActiveSpreadsheet().toast('CSVファイルの集約が完了しました。', '完了', 5);
  var url = spreadsheet.getUrl();
  Logger.log('スプレッドシートのURL: ' + url);
}

// json to csv
function json2Csv(fileName, data) {
  // CSV文字列を生成
  let csvContent = '';

  // ヘッダーを追加
  const headers = data.columns.map(column => column.friendly_name);
  csvContent += headers.join(',') + '\n';

  // 各行のデータを追加
  data.rows.forEach(row => {
    const rowValues = data.columns.map(column => {
      const value = row[column.name];
      // CSVの規則に従って、カンマや改行を含む値をダブルクォートで囲む
      return `"${value.toString().replace(/"/g, '""')}"`;
    });
    csvContent += rowValues.join(',') + '\n';
  });

  // CSVファイルをGoogleドライブに保存
  const file = DriveApp.createFile(fileName + '.csv', csvContent, MimeType.CSV);

  // ファイルのURLをログに出力
  Logger.log('CSVファイルが作成されました: ' + file.getUrl());
  moveFileToFolder(file.getId());
}

使い方

  1. URLリスト シートを作って、こんな感じに設定します

jobID部分は後で更新されます

  1. GASに上記の出来上がりコードを貼って generateRedashFiles 関数を実行します

  1. ジョブIDが更新され、トリガーが登録されます

  1. 時間が来るとトリガーが実行され、結果がキャッシュされていれば指定のフォルダにCSVとスプレッドシートが保存されます

終わりに

ところどころ手直しはしたいですが、とりあえず動くものができました。 今回は簡単のために単一クエリに話しを限定しましたが、RedashAPIをGASで動かせることの最大の強みは、パラメータだけ変えたクエリをスプレッドシート上で大量に作れるところだと思います。 なんだかんだパラメータを設定する、、他のパラメータも別画面で設定する、、結果がでるまで待つ。。やっぱり別の設定のがよいな。。設定し直す。。結果がでるまで待つ。。みたいな作業がBIツールを使っているとどうしても発生しがちなので、そこをG Suiteにまかせて富豪的に解決出来るのは他の作業ができて個人的には便利なところかと思っています。

それではさようなら

Amazon Bedrock ワークショップ に参加しました!

はじめに

こんにちは!トモニテにて開発を行なっている吉田です。

今回は先日参加した Amazon Bedrock ワークショップに参加させいただいたのでそこで学んだことについて紹介します!

ワークショップは AWS 様からエブリー向けに開催いただきました。

Amazon Bedrock とは

Amazon Bedrock は、高性能な基盤モデル (Foundation Model) の選択肢に加え、生成 AI アプリケーションの構築に必要な幅広い機能を提供する完全マネージド型サービスです。 特徴としては以下が挙げられます。

  • ユースケースに最適な基盤モデルを簡単に試すことができる
  • 調整や検索拡張生成 (RAG) などの手法を使用してデータに合わせて非公開でカスタマイズ可能
  • サーバーレスであるため、インフラストラクチャを管理する必要がない

aws.amazon.com

ワークショップの流れ

当日の流れとしては簡単な自己紹介から始まり AWS の方に今回のテーマである Amazon Bedrock(以下、Bedrock とします) について、Bedrock を利用するにあたり必要となる知識について講義をいただきその後ワークショップ用にご準備いただいたソースを使い各自で使ってみるという流れでした。

その中で学んだ Bedrock とその周辺知識について以下に簡単に紹介します。
Bedrock は、生成 AI アプリケーションの構築に必要な幅広い機能を有していますが、そもそも AI とは、人間のように学習し、理解し、反応し、問題を解決する能力を持つ技術のことを指します。
また基盤モデルとは、大量のデータから学習し、広範な知識と能力を持つ大規模な機械学習モデルのことで、その特徴は、入力プロンプトに基づいて、さまざまな異なるタスクを高い精度で実行できる点にあります。
タスクには、自然言語処理 (NLP)、質問応答、画像分類などがあり、テキストによるセンチメントの分析、画像の分類、傾向の予測などの特定のタスクを実行する従来の機械学習モデルと比べて、基盤モデルはサイズと汎用性で差別化されています。
基盤モデルが可能とすることは以下の通りです。

言語処理

基盤モデルには、自然言語の質問に答える優れた機能があり、プロンプトに応じて短いスクリプトや記事を書く機能さえあります。また、NLP 技術を使用して言語を翻訳することもできます。

視覚的理解

基盤モデルは、特に画像や物理的な物体の識別に関して、コンピュータビジョンに適しています。これらの機能は、自動運転やロボット工学などのアプリケーションで使用される可能性があります。また、入力テキストからの画像の生成、写真やビデオの編集が可能です。

コードの生成

基盤モデルは、自然言語での入力に基づいて、さまざまなプログラミング言語のコンピュータコードを生成できます。基盤モデルを使用してコードを評価およびデバッグすることもできます。

人間中心のエンゲージメント

生成 AI モデルは、人間の入力を使用して学習し、予測を改善します。重要でありながら見過ごされがちな応用例として、これらのモデルが人間の意思決定をサポートできることが挙げられます。潜在的な用途には、臨床診断、意思決定支援システム、分析などがあります。 また、既存の基盤モデルをファインチューニングすることで、新しい AI アプリケーションを開発できます。

音声からテキストへ

基盤モデルは言語を理解するため、さまざまな言語での文字起こしやビデオキャプションなどの音声テキスト変換タスクに使用できます。

aws.amazon.com

一方で基盤モデルが苦手とすることもあります。

  • 基盤モデルは大量の GPU を消費する(=コストがかかる)
    • 適材適所の基盤モデル利用が重要
  • Hallucination
    • 嘘の情報を答えてしまう
  • プロンプトのトークン数の制限
    • プロンプトに含められるトークン数には基盤モデルによって制限がある
  • 回答に冪等性がない
    • 特定の入力に対して毎回同じ結果を返すとは限らない

(ワークショップ内資料より引用)

これら基盤モデルの苦手ポイントを解決するために有効な手法の一つとして RAG(Retrieval Augmented Generation)が挙げられます。 RAG とは外部の知識ベースから事実を検索して、最新の正確な情報に基づいて大規模言語モデル(LLM)に回答を生成させることができます。 次節では実際に RAG を用いてタスクを実行してみます!

実際に使ってみた

Amazon Bedrock の Knowledge Bases for Amazon Bedrock(以下、Knowledge Bases とします) を使用すると、Bedrock の基盤モデルを、RAG のために企業データに安全に接続することができるとのことで実際に使ってみました!
Knowledge Bases ではデータソースとしてS3を指定し埋め込みモデルを選択します。(今回はAmazon Titan Embeddingsを選択)
そしてベクトルデータベースについては新しいベクトルストアを作成するか、他で作成したベクトルストアがある場合にはそれを設定することもできます。今回は初めての利用ということで新しくベクトルストアを作成しました。

データソースには弊社のサービスであるトモニテで 2023 年 8 月に実施された「トモニテ子育て大賞 2023」と昨年実施の「MAMADAYS 総選挙 2022」の内容を保存しました。(1 つのバケットに 2 つのオブジェクトがある状態です。)

election2023.tomonite.com

tomonite.com

※2023 年 8 月にブランドリニューアルを行ったことから名称が異なっております
関連記事はこちら tech.every.tv

使ってみた結果がこちらです!

質問内容:mamadays 総選挙、トモニテ子育て大賞それぞれのデカフェ飲料部門の最優秀賞について教えてください

回答: ※Knowledge Bases for Amazon Bedrock 実行結果のスクリーンショット

実際のコンテンツ:(左: MAMADAYS総選挙 2022、右: トモニテ子育て大賞 2023) ソースを明示した上で質問に回答しており、2 つのバケットから異なる情報を引き出すことができていました!

ただ質問によっては片方の情報のみ回答したり、異なる回答をすることもありましたが今回は web ページをそのまま pdf 化して保存しただけだったのでページ内の情報を適切にテキスト化した上でソースとして保存すればより回答の正確性は向上するのかなと思いました! 現に、ほとんど文字の羅列である企画書をソースとして保存しその内容について回答を求めた場合はほぼほぼ適切な情報を返してくれていました。

ワークショップでの学び

普段は SE として業務に関わっていることもあり AI や機械学習に関わる機会は少ないですが今回のワークショップは Bedrock の深い理解を得ることができ、非常に有意義な時間となりました。学んだことを社内に持ち帰り業務に役立てていきたいと思います。

終わりに

ワークショップの開催にあたり、多くのリソースを提供していただいた AWS の皆様に心から感謝申し上げます。