TECH PLAY

ニフティ株式会社

ニフティ株式会社 の技術ブログ

500

この記事は、 ニフティグループ Advent Calendar 2022 24日目の記事です。 はじめに 昨日に引き続き、会員システムグループの山田です。 前回はJetpack Composeのよかった点についてでしたが、今回はイマイチだった点になります。 イマイチだった点 前回同様、記載するコードは簡略化の都合上、一部の属性値などを省略しています。 Navigation Composeがつらい Jetpack Composeで画面遷移を実装しようとする場合、手段は大きく2通りあります。 FragmentでComposeを描画し、Activity上でFragmentを切り替えることにより画面遷移する fragmentTransactionを使うか、 Navigation Component を利用する Fragmentを使わず、Activity上で描画するCompose関数を切り替える 後者を実現するためのライブラリが Navigation Compose です。これはNavigation ComponentのJetpack Compose版なのですが、使い勝手が大きく異なっています。 定義の違い Navigation Componentでは、画面遷移の定義は以下のようにXMLで行っていました。 <navigation xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/nav_graph" app:startDestination="@id/fragment1"> <fragment android:id="@+id/fragment1" android:name="com.example.navigationsample.Page1Fragment"> <action android:id="@+id/action_fragment1_to_fragment2" app:destination="@id/fragment2" /> </fragment> <fragment android:id="@+id/fragment2" android:name="com.example.navigationsample.Page2Fragment" android:label="Fragment2" tools:layout="@layout/fragment_page2"/> </navigation> <navigation>の中に各画面を<fragment>として設置し、IDやFragmentのクラスなどを指定 最初に表示される画面はstartDestination属性で指定 画面間の遷移は<action>として指定 一方、Navigation Composeでは以下のように指定します。 @Composable fun Router() { val navController = rememberAnimatedNavController() NavHost( navController = navController, startDestination = "page1", ) { composable("page1") { Page1(navController) } composable("page2") { Page2(navController) } } } NavHost関数の中に各画面をcomposableとして設置し、呼び出すComposable関数を指定 最初に表示される画面は引数のstartDestinationで指定 画面間の遷移の定義は存在しない actionの定義がない以外はNavigation Componentとほぼ同じような記述ですが、使ってみると辛さが表れてきます。 画面遷移が型安全でない Navigation ComponentではSafe Argsという機能があり、XMLの定義からクラスを自動生成してくれます。これを利用して画面遷移が以下のように行えました。 val action = Page1FragmentDirections .actionFragment1ToFragment2() findNavController().navigate(action) Page1FragmentDirectionsが自動生成されたクラスで、これはXMLに記載されたactionの定義から作られています。このため、XMLに記載されていない遷移を呼び出すことを防止できていました。 一方、Navigation Composeでは以下のようになります。 navController.navigate("page2") 画面遷移は遷移先の画面を文字列で指定します。誤った文字列を与えてしまうことも起こり得ますし、リファクタリングで名前を変えたとしても追従できません。 画面間の引数渡しがつらい 画面遷移時に画面間でデータの受け渡しを行おうとするとより辛くなります。 Navigation Componentではactionに引数を設定することができます。 <action android:id="@+id/action_fragment1_to_fragment2" app:destination="@id/fragment2"> <argument android:name="text" app:argType="string" android:defaultValue="hoge" /> </action> こうして設定された引数や引数の型は自動生成クラスにも反映されるので、以下のように型安全に利用できます。 val action = Page1FragmentDirections .actionFragment1ToFragment2("hoge") findNavController().navigate(action) 内部ではBundleが利用されており、引数がBundleに詰められて遷移先Fragmentに渡されることになります。 一方でNavigation Composeでは以下のようになります。 composable( "page2/{text}", arguments = listOf( navArgument("text") { type = NavType.StringType } ) ) { backStackEntry -> val text = backStackEntry.arguments?.getString("text") Page2(navController, text) } 画面間のデータ渡しはURL形式の文字列により行われます。必須引数はパスパラメータ、非必須パラメータはクエリパラメータの形で記述します。 遷移の呼び出し側は以下のようになります。 navController.navigate("page2/hoge") ここでも型安全性が失われています。文字列としてはなんでも渡すことができてしまうため、ビルド時に誤りに気づくことは不可能です。 加えてURL形式文字列であることもネックで、 引数にスラッシュなどが含まれる 引数がバイト列である などの場合はエスケープやBASE64エンコードを自分で行う必要があります。当然、受け取る側は逆の処理が必要になります。 これらをなるべく安全に扱えるように、マイ ニフティではsealed classによる遷移先管理を行っています。Googleの公式サンプルでは定数値やenumなどで管理を行っているようです。 sealed class MainRoute(val route: string) { object Page1 : MainRoute("page1") object Page2 : MainRoute("page2/{text}") { fun createRoute(text: string) { return "page2/${text}" } } } 遷移アニメーションの不具合 Navigation Componentでは画面遷移時のアニメーションが設定できたのですが、Navigation Composeにはその機能が(2022年12月現在)存在しません。 従来あった機能でJetpack Composeに未実装な機能を補完するものとして、 Accompanist ライブラリが存在しています。事実上の半公式ライブラリで、ここにNavigation Animationというものが存在するのでこちらを併用することになるのですが、これを利用したアニメーションに不具合があります。 まずはNavigation Componentで実装したものです。 1つ目の画面の上に2つ目の画面がスライドインし、戻る操作でスライドアウトします。 一方でNavigation Composeでの実装です。 スライドインは正常に行われるのですが、スライドアウトがおかしくなっています。 現状のAccompanistの実装では画面のスタック状態を考慮しておらず、常に遷移先の画面が上に描画されます。このため、戻る操作を行うと描画順が逆転してしまい、このような結果になります。 マイニフティではこれを解消できなかったため、遷移時に画面同士が重ならないようなアニメーションのみを利用することで回避しています。 先月公開されたAccompanist 0.27.1で z-orderの変更が入った ため、現在ではこの不具合は修正されている可能性があります。 AppBarを操作できない 従来のActivityとFragmentによる実装の場合、画面間で共通して利用するAppBarやBottomAppBarのようなコンポーネントはActivityで実装し、切り替わる画面のみをFragmentで実装することが多かったかと思います。マイ ニフティを従来の方法で実装するなら以下のように分割したでしょう。 共通利用とは言いつつ、AppBarの表示内容は画面によって異なることが多いかと思います。AppBarの領域はFragmentの管理外になるはずですが、FragmentのonCreateMenu()を利用することで例外的に書き換えが可能でした。 Navigation ComposeではFragmentを利用しないため、このような書き換えが不可能です。 画面別に出し分けを行うには、上位の現在の画面状態をStateに持って TopAppBar( title = { if (currentScreen == "page1") { Text("Page1") } else { ... } } ) のような分岐処理を入れざるを得ず、画面数が増えるほど分岐が増える好ましくない実装になってしまいます。 非効率にはなってしまいますが、マイ ニフティでは共通部分を作らず、各画面で別々にAppBarを持たせる実装としています。 従来のViewとの連携 Jetpack Composeがリリースされたとはいえ、すべての機能がJetpack Composeで記述できるようになったわけではありません。WebViewをはじめとして、旧来の仕組み(View)でしか記述できないものは残っています。 Viewとの混在は想定されていて、例えばComposeの中でWebViewを使いたければAndroidViewというものがあります。単純な実装ではこれで十分でしょう。 @Composable fun ComposeWebView(url: string) { AndroidView( factory = { context -> WebView(context) }, update = { view -> view.loadUrl(url) } ) } しかしマイ ニフティの場合はPull to Refreshの機能が存在しており、実装当初は Pull to RefreshをJetpack Composeで実装 WebViewをAndroidViewでラップして実装 という状態でした。Pull to RefreshとWebViewはどちらもスクロールの機能を持つため、Nested Scrollを利用してスクロールを制御する必要があるのですが、これがJetpack ComposeとViewの間で伝播せず、どちらかのスクロールが機能しない状態となっていました。 このため、マイ ニフティではPull to Refresh機能ごとView側で実装しているのですが、この弊害としてWebView利用部分のプレビューが行えないという状態になっています。 一部端末での不具合 一部の端末で予期しない動作をすることがありました。例えばXiaomiのMIUI 13を搭載した端末において、作成していない真っ白の画面が挿入される問題を確認しており、 issue に上げています。 ほとんどの端末では問題なく動作するのですが、特にOSに対するカスタマイズが多いメーカーの端末では注意する必要がありそうです。 おわりに Jetpack Composeのイマイチな部分についてご紹介しました。 見ていただければ分かるとおり、ほとんどの問題はNavigation Composeによるものでした。 ここだけは従来の方法と比べて機能的なデグレードが大きく、明確に使いづらいと言える部分です。画面遷移を引き続きFragmentで行うような実装も可能なので、すべてJetpack Composeで書くことを諦めるということも十分選択肢に入るのではないかなと思います。 総合的にはJetpack Compose導入の利点の方が大きく、今後はこちらが主流になっていくと思われますので、まだ導入されていない皆様もぜひ導入を検討してみてください。 明日はたけろいどさんによる「サービス開発にSvelteKitを導入するために行なったアプローチ」です。お楽しみに。 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
この記事は、 ニフティグループ Advent Calendar 2022 23日目の記事です。 はじめに 会員システムグループの山田です。 ニフティでは昨年12月(iOS)と今年3月(Android)に、会員様向けアプリとして マイ ニフティ をリリースしました。 ニフティとしては久しぶりの新規アプリ開発となり、既存アプリのレガシー化も進んでいたことから、本アプリの開発ではゼロベースで技術スタックを見直し、現在のアプリ開発において標準的な技術に揃えることにしました。 その中でAndroidにおいてはUI構築にJetpack Composeを選定したので、その結果どうだったか、ということについてお話しします。 Jetpack Composeとは 2021年にバージョン1.0.0が公開された、Android用の新しい公式UIツールキットです。 Androidではその登場以来、XMLでUIを記述し、Java/Kotlinで操作するというスタイルでUIの構築が行われてきました。DatabindingやView Bindingなどの補完技術が登場しても、この基本は変わらず一貫していました。 Jetpack Composeはこれを覆す転換点となるツールキットです。主に以下のような特徴を持ちます。 UIをすべてKotlinの関数により記述する 宣言的UIの採用 特にReactで一大ムーブメントを巻き起こした宣言的UIの採用が特徴的で、今までの「XMLに書いたものをコードから変化させる」スタイルから「UIをを予めすべて定義しておいて、データ状態に応じて自動的に変化する」スタイルに一変しています。 詳細についてはAndroid Developersに 公式の解説 がありますので、そちらもご参考ください。 よかったこと 記載するコードは簡略化のため、一部の属性値などを省略しています。 記述がシンプルになる UIが非常にシンプルに書けるようになり、UI記述にかかる時間を大幅に削減することができています。 特に以下の点が効いています。 レイアウトのネストを気にする必要がない 上のようなレイアウトを組もうとした場合、従来の方法でシンプルに書くと以下のような構造になります。 <LinearLayout android:orientation="horizontal"> <ImageView /> <LinearLayout android:orientation="vertical"> <TextView /> <TextView /> </LinearLayout> </LinearLayout> しかしこれはよくないとされる記述です。 従来のAndroidのレイアウトではネストが深ければ深いほど、加速度的にレンダリング時間が増加するという問題が存在します。したがってこのような記述は避け、ConstraintLayoutをはじめとする複雑なレイアウト方法をとり、なるべくフラットに記述する必要がありました。これは学習負荷が高く、またサッと組むには時間のかかる方法です。 Jetpack Composeではネストの問題が解決されているため、このような考慮が不要です。 @Composable fun ArticleRow(article: Article) { Row { AsyncImage(model = article.imageUrl) Column { Text(article.title) Text(article.body) } } } Jetpack Composeではアノテーションを付けた関数(Composable関数)でUIを記述します。この中でRowとColumnが従来のLinearLayoutに対応しています。 このように見た目通りの構造を記述しても、レンダリング速度が大きく落ちることがありません。 リストもシンプルに書ける 従来、リスト形式のUIを作成する場合はRecyclerViewを使用することが多かったと思います。 RecyclerViewを使うためには面倒な準備が必要で、 DiffUtilを使ってデータの同一性判定を定義 RecyclerView.ViewHolderを継承してView保持クラスを定義 RecyclerView.Adapterを継承してデータとのバインディングを定義 という手順を踏んでようやく使えるようになります。 Jetpack ComposeではLazyColumnを使えばよく、 @Composable fun ArticleList(articles: List<Article>) { LazyColumn { items( items = articles, key = { article -> article.id } ) { article -> ArticleRow(article) } } } このように簡単な記述でリストが記述できます。 プレビューが容易 従来のXMLでもプレビューはできるのですが、コードから動的に書き換えられる部分のプレビューが難しいという問題がありました。またレイアウトのネストを深くできないという制約上、UIを細かいコンポーネントに分割してプレビューすることもなかなか難しいということも課題でした。 Jetpack Composeのプレビューは非常に簡単です。UIコンポーネントが全て関数で記述されるので、プレビュー用のデータを引数に与えるだけでレンダリング可能な状態になります。あとは@Previewアノテーションを付与した関数でラップすればプレビューの完成です。 @Preview @Composable fun ArticleRowPreview() { MyTheme { ArticleRow( Article( id = "xxx", title = "タイトル", body = "本文", imageUrl = "https://example.com/image.png" ) ) } } 複数パターンを試したければ引数を変えたものを別途用意するだけで済みます。細かい関数に分けていけばその単位でプレビューが可能なので、ReactなどにおけるStorybookのようなコンポーネントカタログとしての利用が可能です。 React Hooksに近い Jetpack ComposeはReactのHooks APIに近い概念や文法を持ちます。 UIを関数で記述するという基本文法もそうですし、状態や副作用処理に関しても const [state, setState] = useState(0); useEffect(() => { ... }, [state]) val state = remember { mutableStateOf(0) } LaunchedEffect(state) { ... } このように大まかな対応が取れます。 ニフティでは新人教育の中でReactを取り入れているため、アプリ開発にジョインする際の学習コストを抑えることができています。 状態管理と強依存しない Jetpack Composeは専用に用意されたStateの仕組みによって状態を管理します。では全ての状態をStateで管理する必要があるのかというとそうではなく、RxJavaやLiveData、Flowといった従来の状態管理の仕組みと連携することが可能です。 例えばViewModelに保存されたFlow型変数を、以下のようにState型に変換できます。 val state = viewModel.flow.collectAsState() 変換が用意されているため、UIはJetpack Composeで書きつつ、状態管理やデータアクセスは従来通り、Jetpack Composeに依存しない形で記述することが可能です。このため、UI以外のレイヤーは従来と同様の設計を行えばよいことになります。 マイ ニフティではモダン化を行った分、新規に採用するライブラリも多く、経験の少なさがバグや工数増大のリスクになり得ました。その点、中核となる設計に従来の知見を使えることはリスクを低く保つことに大きく貢献しました。 最新のAndroidが必要ない マイ ニフティではiOS版でも同様に技術スタックの見直しを行ったのですが、SwiftUIの採用には至りませんでした。これは開発初期の時点でiOS 10を最低バージョンとしており、SwiftUIに非対応であったためです。便利な機能であってもそれがOSに対して実装される場合、採用可能になるまでは数年間待つ必要があります。 Jetpack ComposeはAndroidXと呼ばれる公式補助ライブラリの一部として開発されており、Android本体とは独立しています。Jetpack Compose 1.0.0の時点でAPI 21(Android 5.0)以上に対応しているため、問題なく採用できました。 おわりに マイ ニフティでJetpack Composeを採用してよかった点を見てきました。特に記述のシンプルさは追加開発や新人のジョイン時にも大きく貢献しており、社内のアプリの中でも頻度の高いアップデートを行うことができています。 一方で開発していく中でイマイチだと感じる点もありました。次回はそちらについてご紹介する予定です。 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
この記事は、 ニフティグループ Advent Calendar 2022 (カレンダー1) 25日目の記事です。 こんにちは、ニフティでAWS/GCPや開発寄りのSaaS管理などしている石川です。 年末ですね、大掃除のシーズンです。 オフィスやキャビネットの掃除はしてても社内情報の掃除はみなさんしているでしょうか。 本日は Notion様の事例紹介として載せさせていただいた内容 の詳細版として、ConfluenceをNotionに移行する前に行った大掃除とNotion上での情報の配置の話をしようと思います。 本記事の以下の流れで説明していきます。 削除と取捨選択 分類分け 再配置 Confluence Serverの大掃除 まずは移行前に身軽にしておきたいので大掃除から、と言っても棚卸し作業が主です。 重複添付ファイルの削除 移行対象の取捨選択 ほぼ空のスペースの削除や集約 重複添付ファイルの削除 Confluence Serverはページコピーの際に添付ファイルも複製されます。定例ページなどコピーして新規ページを作っていると雪だるま式に添付ファイルが増えていってしまいます。これが大量にあるので一番古いファイル以外を消します。 (Confluence豆知識:2016年からページコピーで添付ファイルが複製されるようになり、2019年に複製しないオプションが追加されましたがデフォルトでは複製します。Cloud版も同様の仕様ですが、複製しないオプションはCloud版にはないため必ず複製されます) 削除方法ですがSQLで抽出してAPIで消すだけです(かなり時間はかかります) SQLで同一スペース内にあるファイル名&サイズ&バージョンが同じものが n個以上あるファイルを抽出 APIを叩いて消していく 直近で行った結果だと、 全体の15%ほどが重複ファイルでした 。2020年に初めて行ったときは 50%近くが重複ファイルだった ので、これでも重複数は減った状態です 移行対象の取捨選択 移行先にゴミを持ち込まないために重要なステップです。 今回はヒアリングシート作って移行先(4択)を入力してもらう形で行いました。 デフォルト値が空だとどうしても 「使うかわからないけどとりあえず移行するか」 と考える方が多い ので、各スペースの利用統計から推奨値を入れておくのはやっておいたほうがいいです。また明らかに情報が少ないスペース(議事録だけ数ページだけある)は、全社共通議事録DBを移行先にする対応を行うとより良いですね。 例:長年更新がない、アーカイブスペースになっている → デフォルト 移行しない 例:数年更新がない、近年アーカイブスペースになった → デフォルト エクスポート 移行先問わず使える知見がAtlassianから提供されているので一度目を通してみるといいと思います。 参考: Confluence のベストプラクティス | アトラシアン | Atlassian 参考: ACE#43 Atlassian Cloudへの道のり – YouTube     情報の分類分け 次に移行前にやっておきたいのは情報の分類分けです。 Confluenceのスペースを維持した情報管理 Confluenceから移行した関係上、スペースという概念を保持しておく必要がありました(Notionらしく全部ひとつのDBでドキュメント全部管理するというのは新規ならいいが移行だと難しい)。そのためニフティでは特定階層にあるページを スペースページ と呼称し、情報を取り扱うひとつの粒度として扱っています。 移行の際にConfluenceのスペースをざっくり Dept 、 Knowledge 、 Product に分類し3つのチームスペース内にスペースページとして移しています。メタ情報的にはもう少し細かく分類しているのですが、チームスペースとしてはこの3種にしています。 会社の組織構造に合わせてチームスペースを切っていないのは、もともとプロダクト・プロジェクト単位で情報を集めていることが多かったのでそれを踏襲しています。   Notionで実現したかったことと情報配置 仕上げにNotionでどう配置しているのかについて。 検索ノイズを減らし ここになかったらないですね を実現したい 情報が貯まっていくと、検索ノイズが多くなり検索結果は返ってくるけど目当てのものがない、そもそもストック情報として存在しているかどうかもわからない、という状況がままあります。 そこで検索ボリュームが大きい、全従業員向け・特定職務向け・特定ツール利用者向け・オンボーディングなどはすべて Knowledgeチームスペース に集約しました。 ここを検索することで正確性が担保された情報を見つけやすくなる上に、見つからなかったらNotion上にはなくまだストック化されていない情報だと判別することができるようになります。 なにかしら検索結果が返ってきてしまうと、うまく探せてないのではないかと再検索を繰り返してしまうものですが、結果が返ってこないか少数であれば ここにはないのか と思い、早い段階で別のアクションに移ることができます。   俯瞰して全体を眺め 上から辿っていけるようにしたい 行なったことは大きく3点です。 スペースページDBの作成 カオスマップの再現 索引の作成 スペースページDBの作成 Confluenceにあったスペースディレクトリの代わりとして、スペースページのインデックスDBを用意しました。   ツールカオスマップをNotionで再現 探すべきスペースページが多すぎると選べない場合があるため、カオスマップをNotion上で再現するようにリンクを配置したページを用意しました(ここはいずれDBでうまく再現したい) 索引の作成 主要なDBへリレーションして索引として機能するDBも用意しました。 Confluenceのときは問い合わせ先一覧という大きなテーブルを作っていたのですが、これに索引の機能を付与しました。主要なDBにリレーションするだけで情報が集まってくるため前よりもメンテナンス性が上がったと思います。ここはNotion事例紹介にあった企業様のやり方を一部真似させてもらいました。 参考: 入社1ヶ月でNotionを使いやすい形にデザインした話|西山 将平(Shohei Nishiyama)|note タグとして単語選びと粒度は今後どう運用していくかは課題となっています(一旦Folksonomiesで運用して数を増やし、後でTaxonomyとして整理管理するのがいいか?)   最後に Confluenceを現在お使いの方もこれからCloud版や他へ移行予定の方も、移行前に掃除をしっかり行っておくと移行数も移行時間も大きく削減されるので是非やりましょう。 情報の整理については、既存の情報を実際に見にいってどういう情報がどういう形で置かれているのか把握するのは重要だと感じました。時間はかかりますが、やる前と後では情報に対する解像度が変わります。知識的なものとしてはオライリーの情報アーキテクチャの本が頭の整理に役立ちました。 参考: O’Reilly Japan – 情報アーキテクチャ 第4版 Notion上での組み方ですが、移行前のページ構造を大きく変えずに一部全社共通DB使っていたりとNotionっぽさも混ぜ込みつつ、ひとまずいい感じには使えているのではないかと思っています。組み方に正解はなく各組織ごとに合った構成を見つけていくしかないので難しい部分ですよね、ここは今後も適宜改善していくつもりです。 本記事で ニフティグループ Advent Calendar 2022 は終わりとなります。 多種多様な記事が揃っていますので、是非上記リンク先から他の記事も訪れてみてください。   We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
この記事は、 ニフティグループ Advent Calendar 2022 25日目の記事です。 こんにちは、たけろいどです。SvelteKit v1.0.0リリースおめでとうございます!! この日を心待ちにしていました。まさに魔法のように動き、コードを書くたびに驚かされています。 はじめに この記事ではSvelteKitの技術的知見を多くは提供しません。 どちらかというとSvelteKitを本番環境へ採用するまでに至った心構えや気を付けていることを述べていきます。 とはいっても… なにも紹介しないというのも釈然としないので、情報をどう集めているかを残しておきます。 これからSvelteKitを使っていく人の参考になればとてもうれしいです! SvelteやSvelteKitはとても新しいほうのフレームワークです。そのため日本語の情報は少ないです。さらにSvelteKitのv1.0.0の情報は日本語に限らず情報が少ないです。 その時に頼りになるのが公式ドキュメントです! Svelte SvelteKit Svelteは公式ドキュメントがとても優秀で開発に必要な情報は7割方こちらを参考にしています。 UseCaseが欲しい場合はExamplesを見てみましょう これら公式ドキュメントは有志の方々が翻訳してくださっています。本当にありがとうございます。 SvelteJP SvelteKitJP 他3割はSvelteのGithubを見ることが多いです。issueやdiscussionを覗いてみましょう。より詳細な情報を入手できるでしょう。 よきSvelte lifeを ということで主題に入ります。 SvelteKit導入までの道のり チーム理解 「SvelteKitを使いたいです!」といっても「Nextでいいじゃん…」と言われることが大半です。 負けないように説得しましょう。確かにエコシステムでは圧倒的な差がありますが… SvelteKitのメリットをチームに紹介しましょう。 コード記述量が少ない。まさに魔法のようなコード Vueのようなテンプレート記法を扱える 仮想DOMを使わない コンパイラであること その他Svelte・SvelteKitのメリットを感じるには Tutorial を触って実際に体験してみましょう! またSvelteについては別の記事で紹介しました。 そちら も読んでみてください。 それに加えて新しい技術にトライすることの楽しさを伝えます。しっかりとした論理を説明できた上で、熱意をぶつければ理解を得やすいです。 ちなみに旧サービスでJinja2などのテンプレートエンジンを扱っているのなら、記法が似ているのでオススメできるポイントが増えます。 サービス理解 そのサービスにSvelteKitは本当に必要でしょうか? サービスの規模感を考えてみるのがよいと思います。 確かにSvelteKitは優秀なフレームワークですが、実用例が極々少ないです。 それゆえの不安を捨てきれません。 大きなサービスを作るのなら引継ぎや保守・運用も大変です。そんなサービスでSvelteKitを使うのは少し怖いですね。 まずは小さなサービスから始めてみるのがオススメです。社内向けサポートツールなど内向きに閉じているサービスもチャレンジしやすくてオススメです。リスクを正しく認識していればチーム理解も得やすいと思います。 技術理解 SvelteKitでできることを知りましょう。 たいていのことはできると思いますが不得意なこともあります。 ゲームを作ることは難しいですし、エコシステムもNextに比べたらまだまだです。 Githubや ShowCase などをみて、すでに動いているサービスはどんな実装なのか見てみると勉強になります。 またあくまでもアプリケーションフレームワークです。目的はアプリケーションを作ることでそのSvelteKitは便利な手段を提供しているにすぎません。 それを念頭にSvelteKitでよりよい開発をしていきましょう。 最後に SvelteKitを導入する上でこれら3つの理解を深めていくことが重要だと感じました。 SvelteKitは使い勝手がよく素晴らしいフレームワークです。本番環境で使うかどうかは置いといても触れておいて損はないです。 この記事を読んでSvelteKitを本番に導入した事例が増えれば幸いです。 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
こんにちは、新卒2年目のRyommです! この記事は、 ニフティグループ Advent Calendar 2022 24日目の記事です。 クリスマスイブです!今日はSexyZoneがデビューから11年目にして初めて京セラドームで単独ライブを行った記念すべき日ですね!私も今日は京セラドームに来ています! 今回はSexyZoneから受け取ったエネルギーで作ったスタンプラリーbotを紹介します! はじめに ニフティではクラウド・ゴールデン・ジム(以下CGG)というクラウド人材を育てるための社内勉強会を開催しています。 私はこのCGGの運営として、より多くの人に参加してもらうことで社内全体の技術力の底上げにつなげるべく、いくつか参加のモチベーションとなるような仕掛けを準備しました。 その一環として、ジムから連想してスタンプラリーを作ることにしました。 スタンプラリーの要件 スタンプラリーの要件は以下の通りです。 勉強会への出欠と、勉強会で行うクイズの結果をもとにスタンプのランクが変わるようにしたい 誰でも参照できて、誰がどんなスキルを持っているかの指標になるようにしたい 簡単に参照できるようにするため、Slackでbotとして呼び出したい @hogebot command [target] のような形式で問い合わせると、対象の画像が返却されてslack上のプレビューで見られるようにしたい 管理画面を作る余力はないので、データの管理はGoogle SpreadSheetで行いたい できたもの helpコマンド 検索結果が1つだけのとき 検索結果が複数あるとき 検索結果に合致するデータがないとき 有効なコマンドがないとき 作ってみた 概要は以下の通りです。 bot応答部分の骨組みを Lambda + API gateway + Slack App で作成 画像合成部分を Google SpreadSheet + GAS ( + S3 )で作成 合成した画像を S3 にアップロードしてURLを SpreadSheet に保持する部分を作成 作成したURLと名前などをセットにして DynamoDB にアップロードする部分を作成 botが受け取った値を用いて DynamoDB を検索して該当のスタンプラリーカードURLを返却する部分を作成 構成図 1. bot応答部分の骨組みを Lambda + API gateway + Slack Appで作成 まずは基礎となるbot部分を作ります。 ここではSlack botにメンションをつけてメッセージを送ると、Lambda側でメッセージを受け取ることができ、メッセージに応じて何かしらの返信をするようにします。 Slack App作成 Permissionを設定する app_mentions:read メンションされたメッセージを読み込むために必要 channels:history 公開チャンネルでメンションされたメッセージを読み込む chat:write チャットに書き込むために必要 im:history DMのメッセージを読み込むために必要(現状実装していないので今は使っていない) users:read データ投入時に名前からslack名を取得するために必要 users:read:email データ投入時にemailを使って照合するために必要 Scopesの設定 発行されたトークンを後述のlambdaの環境変数に設定するため、コピーしておきます。 API Gateway作成 slackAPIから Content-Type が application/x-www-form-urlencoded でイベントが送られてくるので、マッピングテンプレートを仕込んでおきます。 右上の統合リクエストを開く マッピングテンプレートのリクエスト本文のパススルーは「 リクエストの Content-Type ヘッダーに一致するテンプレートがない場合 」を選択し、 application/x-www-form-urlencoded マッピングテンプレートを作成し、 こちらの記事 にあるコードを貼り付けます。 Lambda作成 ひとまず @cgg-stamp-rally hello とメッセージを送ると「何?」と返すようにします。 import json import os import logging import urllib.request logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(data, context): # challenge # slack api との連携に必要 if ('challenge' in data): return { "statusCode": 200, "body": json.dumps({'challenge': data['challenge']}) } # slack event event = data["event"] # メンション時 if (event["type"] == "app_mention"): # helloコマンド if ("hello" in event["text"]): send_slack("何?") return { "statusCode": 200 } def send_slack(message): url = 'https://slack.com/api/chat.postMessage' token = os.environ['SLACK_TOKEN'] channel = "#specific_channnel" headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json; charset=utf-8' } method = 'POST' data = { "channel": channel, "text": message } json_data = json.dumps(data).encode("utf-8") req = urllib.request.Request(url=url, data=json_data, headers=headers, method=method) res = urllib.request.urlopen(req, timeout=5) tokenなど機密情報はLambdaの環境変数に設定しています。 challenge Slack Appで連携する際に、challengeパラメータを受け取って疎通確認を行います。 We’ll send HTTP POST requests to this URL when events occur. As soon as you enter a URL, we’ll send a request with a challenge  parameter, and your endpoint must respond with the challenge value.  Learn more. slack api Slack App Event Subscriptions設定 Slack Appを開き、 Add features and functionality の Event Subscriptions の Request URL に先ほど作成したAPIのエンドポイントを貼り付けて疎通確認します。 Subscribe to bot eventsに app_mention 、 message.channels を設定します。 message.im は不要・・・ 疎通確認 ここまででSlackとの疎通ができるようになりました! Lambdaを作り込む 最終的にコマンドで検索できるようにしたいので、検索ワードの抽出ができるようにしています。 また、helpコマンドと、指定のコマンドがない時はhelpコマンドを呼ぶように誘導するメッセージを出すようにしました。 botがメッセージを送るチャンネルも固定にしていたところを、メンションが呼び出されたチャンネルに投稿するように変更しました。 import json import os import logging import urllib.request logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(data, context): # challenge # slack api との連携に必要 if ('challenge' in data): return { "statusCode": 200, "body": json.dumps({'challenge': data['challenge']}) } # slack event event = data["event"] # メンション時 if (event["type"] == "app_mention"): # helpコマンド if ("help" in event["text"]): msg = '''`@CGGスタンプラリー help`:このメッセージを表示する `@CGGスタンプラリー hello`:何?と返す `@CGGスタンプラリー data`: 渡ってくるデータを返す(開発用) `@CGGスタンプラリー ref [検索文字]`:氏名・slack名で検索(複数指定でAND検索) ''' send_slack(event["channel"], msg) # helloコマンド elif ("hello" in event["text"]): send_slack(event["channel"], "何?") # どんなデータが渡ってくるか確認するため elif("data" in event["text"]): send_slack(event["channel"], data) # refコマンド elif ("ref" in event["text"]): # 検索文字を抽出 search_words = event["text"].split() # ワードからbotメンションを削除 del search_words[0] # ワードからrefコマンド文字列を削除 search_words.remove("ref") send_slack(event["channel"], search_words) # その他のコマンド else: send_slack(event["channel"], "意味がわかりません!helpでコマンドを確認してください!") return { "statusCode": 200 } def send_slack(channel, message): url = 'https://slack.com/api/chat.postMessage' token = os.environ['SLACK_TOKEN'] headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json; charset=utf-8' } method = 'POST' data = { "channel": channel, "text": message } json_data = json.dumps(data).encode("utf-8") req = urllib.request.Request(url=url, data=json_data, headers=headers, method=method) res = urllib.request.urlopen(req, timeout=5) 2. 画像合成部分をGoogle SpreadSheet+GAS(+S3)で作成 スタンプラリーは出欠と理解度確認クイズのスコアを基にスタンプの色を出し分けるようにしたいです。 そのため、それぞれの入力に対応してバッジをセットするGASを仕掛けてありますが、ここでは詳細説明を省略します。 画像合成は、大まかに以下の手順で進めます。 ベースになるhtmlを作成 管理シートのデータを基に名前やスタンプ画像を埋め込む html2canvas を用いてcanvasに変換 toDataURL("image/png") を用いてPNG画像に変換 管理シートを作成する 管理シートのカラムは以下の通りです。 name:実名 slack_name:slackの名前 image-url:合成した画像のURL #0attend:#0(講義のナンバリング)の出欠 meetの出席レポートから入力する #0score:#0のクイズのスコア #0badge:#0attendと#0scoreの値を加味して振り分けられたバッジ S3を作成し、CORS設定を行う スタンプの画像はS3でホストしておきます。 社内だけでなくグループ会社の方も使う予定のため、画像が閲覧できるようにパブリックに公開できる設定にしています。また、独自ドメインをあえて設定するような用途ではないため、CloudFrontは挟んでいません。 次に、CORSに Access-Control-Allow-Origin を設定します。 CORS設定 curlで叩いてheaderにキチンと含まれているか確認します。 ※ Access-Control-Allow-Origin: * にしてるので呼び出す側のoriginはなんでもいいです。 curl -i {S3のURL}/{画像名}.png -H "Origin:{GASの呼び出す側のURL}" --head HTTP/1.0 200 Connection established HTTP/1.1 200 OK . . . Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, HEAD Access-Control-Expose-Headers: Access-Control-Allow-Origin . . . バッジ画像リスト スプレッドシートに新しいシートを作成し、バッジのURLを参照できるようにしておきます。 メニューを作る 毎度GASを開いて実行するのは面倒なので、以下のようにワンクリックで呼び出せるように準備しておきます。 スプレッドシートのメニュー スクリプトファイルを作成し、メニューを追加します。関数を作成したら順次こちらに追加していきます。 // メニューに追加 function onOpen() { const sp = SpreadsheetApp.getActiveSpreadsheet() const myMenu = [ { name: 'メニューが動くか確認', functionName: 'testMenu_' } ] sp.addMenu('自動化ツール', myMenu) } function testMenu_() { Browser.msgBox('メニュー動く') } 土台となるhtmlを作成 スタンプラリーの土台のhtml ローカルでサクッと組みます。 <?=変数名 ?> とすることでHTML生成時に動的に値を埋め込むことができます。大体できたら、GASにindex.htmlで作成します。 GASでwebページをホストする 毎度デプロイで表示させてもいいですが、ちょっと面倒なのでスプレッドシートの右側に表示されるようにします。 先ほど作ったindex.htmlを基に HtmlService.createTemplateFromFile("index") でHtmlテンプレートオブジェクトを作成し、テンプレートの変数部分に値を挿入します。その後 evaluateメソッドを実行してHtmlOutputオブジェクトを生成します。 SpreadsheetApp.getUi().showSidebar(htmlOutput) でサイドバーに生成したHtmlOutputオブジェクトを表示させています。 HTMLサービスやClassUIについて詳しくは公式ドキュメントをお読みください。 https://developers.google.com/apps-script/reference/base/ui https://developers.google.com/apps-script/reference/html/html-service.html 引数の c と columns は、1行分のデータが入った配列とカラム名です。 この関数を各行に対してfor文で回すことで一気に画像生成ができる算段です。 繰り返す時は処理中の行番号とslack名をPropertiesServiceに入れています。 function createHtml_(c, columns) { const html = HtmlService.createTemplateFromFile("index") // 各行の内容を取得してhtmlを生成 for (const [i, v] of columns.entries()){ Logger.log(v) switch (v) { case 'name': html.NAME = c[i] break case '#1badge': html.BADGE_1 = c[i] break case '#2badge': html.BADGE_2 = c[i] break case '#3badge': html.BADGE_3 = c[i] break . . . default: break } } const htmlOutput = html.evaluate() SpreadsheetApp.getUi().showSidebar(htmlOutput) } 画像に変換する htmlは作成できたので、作成後 html2canvas を用いてcanvasに変換し、 toDataURL() でpng形式に変換します。テンプレートのinde.htmlを編集し、以下のようなコードになっています。 <!DOCTYPE html> <html> <head> <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.0.0-rc.5/dist/html2canvas.min.js"></script> <style> * { margin: 0; padding: 0; line-height: 1; font-size: inherit; font-weight: normal; } .wrapper { width: 400px; padding: 1em; background-color: #e2e5ea; } header { text-align: center; margin-bottom: 0.5em; } header h1 { font-weight: bold; line-height: 2em; } .slack-profile { margin: 0 auto; } .slack-profile h2 { font-size: 1.5em; line-height: 3em; text-align: center; word-break: keep-all; } .stamp-rally table { margin: 0 auto; border-spacing: 1em 2em; } .stamp-rally table td { position: relative; width: 80px; height: 80px; border-radius: 0.3em; background-color: #fff; } .stamp-rally table td span { position: absolute; top: 105%; left: 0; width: 100%; height: 1em; text-align: center; word-break: keep-all; font-size: 0.6em; color: #5e6062; } .stamp-rally table td img.badge { width: 80%; margin: 10%; } </style> </head> <body> <div class="wrapper" id="stampCard"> <header> <h1>CGG 2022 スタンプラリー</h1> </header> <main> <div class="slack-profile"> <h2><?=NAME ?></h2> </div> <div class="stamp-rally"> <table> <tr> <td> <img class="badge" src="<?=BADGE_1 ?>" crossorigin="anonymous" /> <span class="description">THE AWS</span> </td> <td> <img class="badge" src="<?=BADGE_2 ?>" crossorigin="anonymous" /> <span class="description">設計GD</span> </td> <td> <span class="description">フロントエンド</span> <img class="badge" src="<?=BADGE_3 ?>" crossorigin="anonymous" /> </td> <td> <img class="badge" src="<?=BADGE_4 ?>" crossorigin="anonymous" /> <span class="description">暴れん坊コンテナ</span></td> </tr> <tr> <td><img class="badge" src="<?=BADGE_5 ?>" crossorigin="anonymous" /><span class="description">サーバーレス</span></td> <td><img class="badge" src="<?=BADGE_6 ?>" crossorigin="anonymous" /><span class="description">CI/CD</span></td> <td> <img class="badge" src="<?=BADGE_7 ?>" crossorigin="anonymous" /> <span class="description">オブザーバビリティ</span> </td> </tr> <tr> <td> <img class="badge" src="<?=BADGE_gd ?>" crossorigin="anonymous" /> <span class="description">障害対応WS</span></td> <td><img class="badge" src="<?=BADGE_quiz ?>" crossorigin="anonymous" /><span class="description">トリビアクイズ</span></td> <td> <img class="badge" src="<?=BADGE_lt ?>" crossorigin="anonymous" /> <span class="description">LT</span></td> </tr> </table> </div> </main> </div> </body> <script> window.onload = function() { const stampCard = document.querySelector("#stampCard") html2canvas(stampCard, {scale:2, allowTaint: true, useCORS: true, taintTest: true, logging: true}).then((ele) => { google.script.run.saveImage(ele.toDataURL("image/png")) }) } </script> </html> html2canvas を使用して指定したオブジェクトをcanvasに変換 <script src="<https://cdn.jsdelivr.net/npm/html2canvas@1.0.0-rc.5/dist/html2canvas.min.js>"></script> toDataURL を使ってcanvasをpng形式に変換 google.script.run.saveImage(png画像) は後述のS3に保存する場面で作成する関数 <script> window.onload = function() { const stampCard = document.querySelector("#stampCard") html2canvas(stampCard, {scale:2, allowTaint: true, useCORS: true, taintTest: true, logging: true}).then((ele) => { google.script.run.saveImage(ele.toDataURL("image/png")) }) } </script> CORSについて GASのhtmlに埋め込んだ画像はCORSに引っかかり、png変換時に出力されません。 その場合、まず画像URLのヘッダーに Access-Control-Allow-Origin を設定します。 自分でホストしている画像であれば、 Access-Control-Allow-Origin: * をヘッダーに含める Slackのプロフィール画像など、ヘッダーを弄れない場合は出力できないので諦める また、html2canvas実行時の useCORS を有効にします。 html2canvas(stampCard, {scale:2, allowTaint: true, useCORS: true, taintTest: true, logging: true}).then((ele) => { google.script.run.saveImage(ele.toDataURL("image/png")) }) さらに、HTML内の 全てのimgタグ に crossorigin="anonymous" を含めます。 <img class="badge" src="<?=BADGE_1 ?>" crossorigin="anonymous" /> ヘッダーに設定した Access-Control-Allow-Origin が消滅するときは以下の現象が起きていると考えられます。私の場合、 crossorigin="anonymous" を全てのimgタグに設定するとエラーが消えました。 S3の画像URLを使用している場合、かつ、Chromeでブラウザのcacheが有効になっている場合、2度目のアクセス時に Access-Control-Allow-Origin Headerの付いていないキャッシュしたレスポンスをChromeが返すためCORSエラーが発生してしまう模様 特定の条件でのみS3の画像URLでCORSエラーが発生する問題をなんとかする – Qiita 3. 合成した画像をS3にアップロードしてURLをSpreadSheetに保持する部分を作成 GASで作成した画像をDriveに保存してリンクを共有するとslack上で画像プレビューされないため、S3にアップロードします。 IAM S3FullAccessを指定し、IAMロールを作成したらアクセスキーとシークレットキーはPropertyServiceに保存しておきます。 S3にアップロードするライブラリ追加 aws-sdk-js を使用するので、GASにライブラリを追加します。 スクリプトID 1Qx-smYQLJ2B6ae7Pncbf_8QdFaNm0f-br4pbDg0DXsJ9mZJPdFcIEkw_ ただし、このライブラリは画像をアップロードする際に改造が必要になります。 GASプロジェクト内にファイルを新規作成し、それぞれ以下のファイルをGASプロジェクト内にコピペします。 https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3.gs https://github.com/eschultink/S3-for-Google-Apps-Script/blob/master/S3Request.gs コピペしたファイルを、以下の記事を参考に修正します。 https://note.com/marina1017/n/n431f0bb4e342 S3にアップロードし、アップロードした画像のURLをスプレッドシートに記録する関数作成 rowNum はfor文で createHtml_() を呼び出す際にPropertyServiceに保存した処理中の行番号です。 function saveImage(img) { // 情報を格納する行 const row = PropertiesService.getDocumentProperties().getProperty("rowNum") // 作成した画像のアップロード先S3の設定 const S3_ACCESS_KEY = PropertiesService.getScriptProperties().getProperty("S3_ACCESS_KEY") const S3_SECRET_ACCESS_KEY = PropertiesService.getScriptProperties().getProperty("S3_SECRET_ACCESS_KEY") const s3 = getInstance(S3_ACCESS_KEY, S3_SECRET_ACCESS_KEY) const S3_BUCKET_NAME = "cgg-stamp-rally" // ファイル名を組み立てる const today = new Date() const name = PropertiesService.getDocumentProperties().getProperty("slackName").trim() const filename = `${("0" + today.getFullYear()).slice(-2)}${("0" + (today.getMonth() + 1)).slice(-2)}${("0" + today.getDate()).slice(-2)}${("0" + today.getHours()).slice(-2)}${("0" + today.getMinutes()).slice(-2)}-${name}` const a = img.replace('data:image/png;base64,', '') const decodedImg = Utilities.base64Decode(a) const imgblob = Utilities.newBlob(decodedImg, "image/png", `${filename}.png`) s3.putObject(S3_BUCKET_NAME, `${filename}`, imgblob, {logRequests: true}) const url = `{S3のURL}/${filename}` const SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID') const s = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName('2022'); s.getRange(Number(row), 4).setValue(url) } 画像をBlobオブジェクトにする必要があるため、data URI schemeのメディアタイプとエンコード方式を削除してからデコードし、メディアタイプとファイル名を指定してBlobを作成しています。 const a = img.replace('data:image/png;base64,', '') const decodedImg = Utilities.base64Decode(a) const imgblob = Utilities.newBlob(decodedImg, "image/png", `${filename}.png`) これをhtml中から呼び出しています。 処理中の行番号とスプレッドシートで持っているslack名をPropertyServiceに入れたのは、 saveImage() のようにhtmlテンプレート内部で呼び出す関数に変数を渡すのが大変なためです。 4. 作成したURLと名前などをセットにしてDynamoDBにアップロードする部分を作成 DynamoDBを使うためにライブラリ追加 GASからDynamoDBに直接データ投入を行いたいですが、認証を突破することが大変なので aws-apps-scripts を使います。メンテ状況を見て不安になりますが、動きました。 GASプロジェクト内にファイルを新規作成し、以下のaws.js内のコードをそのままコピペします。 https://github.com/smithy545/aws-apps-scripts/blob/master/aws.js IAM 権限は以下の2つ AmazonDynamoDBFullAccess batch-submit-policy アクセスキーとシークレットキーをGASのPropertyServiceに設定しておきます。 DynamoDBにデータを登録していく関数作成 DynamoDBを検索することを考えると同じ人のデータは更新していく方が望ましいため、updateメソッドを使います。 本来はDynamoDBに一括でアイテム登録した方がいいですが、ここではfor文で回しています。 function updateDynamodb_() { const DYNAMODB_ACCESS_KEY = PropertiesService.getScriptProperties().getProperty('DYNAMODB_ACCESS_KEY') const DYNAMODB_SECRET_ACCESS_KEY = PropertiesService.getScriptProperties().getProperty('DYNAMODB_SECRET_ACCESS_KEY') AWS.init(DYNAMODB_ACCESS_KEY, DYNAMODB_SECRET_ACCESS_KEY) const SPREADSHEET_ID = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID') const s = SpreadsheetApp.openById(SPREADSHEET_ID).getSheetByName('2022'); const data = s.getRange(2, 1, s.getLastRow(), s.getLastColumn()).getValues() const table = "cgg-stamp-rally" for (const [i, c] of data.entries()) { if (c[1]) { const item = { slackName: {S: c[1]}, name:{S: c[0]}, stampCardUrl: {S: c[3]} } const res = AWS.request( 'dynamodb', 'ap-northeast-1', 'DynamoDB_20120810.UpdateItem', {}, 'POST', { TableName: table, Item: item, Key: { 'slackName': item.slackName }, ExpressionAttributeNames: { '#n': 'name', '#url': 'stampCardUrl' }, ExpressionAttributeValues: { ':newName': item.name, ':newUrl': item.stampCardUrl }, UpdateExpression: 'SET #n = :newName, #url = :newUrl' }, { 'Content-Type': 'application/x-amz-json-1.0' }, ) const code = res.getResponseCode() const text = res.getContentText() if (code < 200 || code >= 300) throw Error(`AWS.request failed: ${code} - ${text}`) Logger.log(`OK: ${table} - ${JSON.stringify(item)}`) } } } 5. botが受け取った値を用いてDynamoDBを検索して該当のスタンプラリーカードURLを返却する部分を作成 lambdaのbot部分に戻り、検索条件に合ったスタンプラリーのURLを返却するようにします。 検索条件に合致するスタンプラリー画像URLを探す関数作成 複数の検索ワードが渡された場合、AND検索するようにします。 検索結果が複数ある場合や、データが見つからなかったときは文言を変えています。 def get_card(targets): dynamodb = boto3.resource('dynamodb') table = dynamodb.Table('cgg-stamp-rally') # 検索文字列をFilterExpressionに指定できる形に整形 fe = None for target in targets: if fe is None: fe = Attr('slackName').contains(target) | \ Attr('name').contains(target) else: fe = fe & Attr('slackName').contains(target) | \ Attr('name').contains(target) res = table.scan( FilterExpression = fe ) items = res['Items'] # 最後まで読み込む while 'LastEvaluatedKey' in res: res = table.scan( FilterExpression = fe, ExclusiveStartKey=resp['LastEvaluatedKey'] ) items.extend(res['Items']) if len(items) == 0: return '条件に合致するデータがありません' # 同姓同名の場合を考慮 if len(items) > 1: prospective_targets = [] for item in items: prospective_targets.append(item['slackName']+':'+item['stampCardUrl']) return "対象ユーザーの候補が複数あります\n" + "\n".join(prospective_targets) target_card = [] for item in items: target_card.append(item['stampCardUrl']) return "\n".join(target_card) FilterExpression で条件を指定してscan 結果がページネーションされている場合を考慮し、 LastEvaluatedKey でページを最後まで読み込む 最後にSlackから @cgg-stamp-rally ref hoge とコマンドを送り、対象のスタンプラリーが返ってくることを確認したら完成です! @cgg-stamp-rally ref hoge おわりに スタンプラリー制作はGAS上で行う画像合成部分が一番大変でした。 かなり荒削りな実装ですが、CGG開催期間中のみ利用する想定なので今の状態で運用しています。 スタンプラリーはかなり反響が高く、さらに副次的な効果としてスタンプラリーきっかけで始めたクイズも理解度が上がると好評となっています。 このクイズは社内研修で行われたジョブローテーション先の サービスインフラチーム の「伝授」の仕組みから着想を得て、勉強会に応用してみました。 CGGはグループを横断して行われる大規模な勉強会のため、テーマであるクラウドサービス以外にも様々な刺激を受けてもらえたらと考え試行錯誤しています。 今後の展望としては、まだまだ改善の余地はたくさんあるシステムなため開発環境の整備が完了次第、社内オープンソース化をして扱いやすくし、社内勉強会においてスタンプラリーが定着してくれたらいいなと考えています。 というわけで、以上がRyommサンタからのクリスマスプレゼントでした! 明日は、 14kw さんの Notionのなんか書く です。お楽しみに! We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
この記事は、 ニフティグループ Advent Calendar 2022 21日目の記事です。 はじめに 会員システムグループ 第二開発チームの川上です。普段はニフティ会員向けiOS/Androidアプリの開発や運用を担当しています。 私のチームはスクラムで開発しており、GitHub Projects(classic)でタスク管理しています。スプリント内の進捗を管理する上でプランニングポーカーでつけたポイントを可視化したいという話がありました。そこで、定期的に自動更新するバーンダウンチャートをGAS(Google Apps Script)で簡易的に用意してみました。 本記事ではこのバーンダウンチャート作成で行った実装について紹介します。 要件 現時点までの残りポイントについて折れ線が表示される スプリント終了日までの予測ポイントについて折れ線が表示される 予測線は残日数の割合で計算 休日はポイント消費しない 定期的に自動更新する 構成 リソース管理や運用の手間を少なくするため、GASでタスクデータを取得して Looker Studio に表示しています。 前提 GitHubのIssueにはチーム独自の運用として下記が設定されており、一部実装はこの内容を前提としています。 Title タスクのタイトル 「:」後にポイントを記載 例) 「〇〇のテストを作成する:3」、「〇〇のインターフェースを追加する:2」 Milestones スプリントを設定 例)「Sprint5」、「Sprint11」 Labels チケットの種類を設定 「PR」「Epic」以外がポイント集計対象のタスク 例)「android」、「ios」、「PR」、「Epic」 実装 1. スプレッドシートを用意 スプレッドシートを新規作成してシートを追加して4つ用意します。 タスク一覧シート GitHubから取得した情報を保存しておくシート Sprint集計シート タスク一覧をスプリントごとに集計したシート 実際にLooker Studioから参照してバーンダウンチャート化する 設定用シート スプリント期間などの情報が記載されたシート 一時計算用シート GASから一時的に書き込む空のシート 2. GitHub API v4でデータ取得してスプレッドシートに収集する GitHub API v4でデータを取得するために、下記の関数を用意します。 function fetchGithubTasks() { const graphql_query = ` query { \ search(type: ISSUE, query: "is:issue org:organization_name project:project_name", last: 100) { \ issueCount \ nodes { ... on Issue { \ id \ milestone { title } \ number \ title \ closed \ closedAt \ createdAt \ author { login } \ assignees(first: 100){ nodes { login } } \ labels(first: 100){ nodes { name } } \ } \ } \ } \ } `; // スクリプトプロパティに登録されたトークンを取得 const token = PropertiesService.getScriptProperties().getProperty("GITHUB_ACCESS_TOKEN"); const option = { method: "post", contentType: "application/json", headers: { Authorization: "bearer " + token }, payload: JSON.stringify({ query: graphql_query }) }; return UrlFetchApp.fetch("https://api.github.com/graphql", option); } 「graphql_query」の文字列で指定している「organization_name」と「project_name」は環境にあった文字列に置き換えてください。また、定期実行されるまでの期間に100件以上更新されることがなかったため、一回の実行でIssueの取得件数は更新日時が新しい順に100件としています。 この関数を利用してデータを取得し、データ変換とスプレッドシートへの書き込みを行います。 const SPREAD_SHEET_ID = "スプレッドシートのID" const TASK_SHEAT_NAME = "タスク一覧シート" const TMP_SHEAT_NAME = "一時計算用のシート" const ignore_labels = [ "Epic", "PR", ] // タスク一覧を取得してスプレッドシートに書き込む関数 function updateTasks() { // GitHubからタスクを取得 const response = fetchGithubTasks() // レスポンスをタスク形式に変換して、一部のlabelに該当するタスクを除去 const result = JSON.parse(response) const sbis = result.data.search.nodes .map(x => convertToTask(x)) .filter(x => !ignore_labels.includes(x.label)) // keyとvalueを分離 const keys = Object.keys(sbis[0]) const records = sbis.map(x => Object.values(x)) // スプレッドシートに書き込み const sheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(TASK_SHEAT_NAME) const tmpSheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(TMP_SHEAT_NAME) // 取得したデータからスプレッドシートを更新 records.forEach(x => { // idから行番号を取得 const row = getRow(tmpSheet, TASK_SHEAT_NAME, x[0]) // IDが存在しない場合は新規追加 if (row == null) { sheet.appendRow(x) return } // 存在する場合は置き換え sheet.getRange(row, 1, 1, x.length).setValues([x]) }) } // スプレッドシートのQUERY関数でIDを検索する関数 // データが多くなったときに線形探索より高速 function getRow(tmpSheet, targetSheetName, id) { tmpSheet.getRange(1,1).setValue(`=QUERY({${targetSheetName}!A:A, ARRAYFORMULA(ROW(${targetSheetName}!A:A))},"WHERE Col1 = '${id}'")`) const row = tmpSheet.getRange(1, 2).getValue() return row != "" ? row : null } // GitHubから取得したデータを整形する関数 function convertToTask(item) { const title = item.title.split(':', 2)[0] const point = parseInt(item.title.split(':', 2)[1] ?? 0) const closedAtJST = item.closedAt ? Utilities.formatDate(new Date(item.closedAt), "JST", "yyyy-MM-dd HH:mm:ss") : undefined const createdAtJST = item.createdAt ? Utilities.formatDate(new Date(item.createdAt), "JST", "yyyy-MM-dd HH:mm:ss") : undefined return { id: item.id, title: title, point: point, closed: item.closed, closedAt: closedAtJST, author: item.author.login, assignee: item.assignees.nodes[0]?.login ?? "", label: item.labels.nodes[0]?.name ?? "", milestone: item.milestone?.title ?? "", createdAt: createdAtJST, } } GASのトリガーにupdateTasks関数を定期実行するように設定します。実行後は次のようなデータがスプレッドシートに書き込まれます。 3. Sprint用のデータに変換する タスク一覧シートにデータ取得できましたが、Looker Studioでバーンダウンチャートのようなグラフを表示するにはこのデータを元に値の加工が必要です。Looker Studio上でも値の加工はできますが、データソースにスプレッドシートを使う場合は複雑な加工ができません。そのため、スプレッドシート側の別シート(Sprint集計シート)で加工を行います。 また、タスク一覧シートの変更を即時にSprint集計シートに反映する処理が必要です。ただ、変更したデータの取得→加工→反映を愚直に実装するのは少し手間がかかるため、GASからはセルにスプレッドシート関数の文字列を書き込むことで実現します。 下記のコードは設定シートに記載された更新日を過ぎたら、次のスプリント日数分の行を追加して、各セルにスプレッドシート関数を埋め込んでいます。(実装を妥協しているので、シートのヘッダーが変わったら崩れてしまいます…) const CONFIG_SHEAT_NAME = "設定用シート" const ACTIVITY_SHEAT_NAME = "Sprint集計シート" // 設定用シートを読み込む関数 function readConfig(sheet) { const rows = sheet.getDataRange().getValues() // Configデータをマップに読み込み const config = {} rows.forEach( (x, i) => config[x[0]] = { value: x[1], index: i } ) return config } // 休日判定用の関数 function isHoliday(date) { // 土日 const day = date.getDay() if (day === 0 || day === 6) return true // 祝日 const id = 'ja.japanese#holiday@group.v.calendar.google.com' const cal = CalendarApp.getCalendarById(id) const events = cal.getEventsForDay(date) if (events.length) return true // その他休日 const otherHoliday = [ '12/28', '12/29', '12/30', '12/31', '01/01', '01/02', '01/03', ]; const fd = Utilities.formatDate(date, 'JST', 'MM/dd') return otherHoliday.some(value => value === fd) } // 次回のスプリントデータをスプレッドシートに書き込む関数 function nextSprint() { const configSheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(CONFIG_SHEAT_NAME) config = readConfig(configSheet) const today = new Date() // 更新日前は何もしない if (today < config.next_sprint_update_date.value) { return } // 設定シートのスプリント番号を更新、次回更新日を設定 const nextSprintNumber = config.current_sprint_number.value + 1 const nextUpdateTime = new Date(config.next_sprint_update_date.value.getTime()); nextUpdateTime.setDate(nextUpdateTime.getDate() + 7 * config.sprint_week_span.value) configSheet.getRange(config.current_sprint_number.index+1, 1+1).setValue(nextSprintNumber) configSheet.getRange(config.next_sprint_update_date.index+1, 1+1).setValue(Utilities.formatDate(nextUpdateTime, "JST", "yyyy-MM-dd HH:mm:ss")) const activitySheet = SpreadsheetApp.openById(SPREAD_SHEET_ID).getSheetByName(ACTIVITY_SHEAT_NAME) const lastRowNumber = activitySheet.getLastRow() const sprintStartDate = new Date(config.next_sprint_update_date.value.getTime()); sprintStartDate.setDate(sprintStartDate.getDate() + 1) // 前処理(日割計算のため、スプリントの実働日数を計算しておく) let working_day_num = 0 for (var d = new Date(sprintStartDate.getTime()); d <= nextUpdateTime; d.setDate(d.getDate() + 1)) { working_day_num = !isHoliday(d) ? working_day_num + 1 : working_day_num } // DailyActivityシートに次のスプリント分のデータを追加する let index = 0 let sprint_elapsed_day = -1 for (var d = new Date(sprintStartDate.getTime()); d <= nextUpdateTime; d.setDate(d.getDate() + 1)) { const currentRowNumber = lastRowNumber + index + 1 // 営業日判定 const is_business_day = !isHoliday(d) // スプリント経過日数(初日を0とする) sprint_elapsed_day = !isHoliday(d) ? sprint_elapsed_day + 1 : sprint_elapsed_day // 特定のmilestoneのうちdの日付に作成されたポイント const today_created_point = `=SUMIFS(${TASK_SHEAT_NAME}!$C:$C,${TASK_SHEAT_NAME}!$J:$J,">="&$A${currentRowNumber}, ${TASK_SHEAT_NAME}!$J:$J,"<"&($A${currentRowNumber}+1), ${TASK_SHEAT_NAME}!$I:$I,"="&$B${currentRowNumber})` // 特定のmilestoneのうちdの日付に完了したポイント const today_closed_point = `=SUMIFS(${TASK_SHEAT_NAME}!$C:$C,${TASK_SHEAT_NAME}!$E:$E,">="&$A${currentRowNumber}, ${TASK_SHEAT_NAME}!$E:$E,"<"&($A${currentRowNumber}+1), ${TASK_SHEAT_NAME}!$I:$I,"="&$B${currentRowNumber})` // 特定のmilestoneのうちdの日付まで作成されたポイント合計 const total_created_point = `=SUMIFS(${TASK_SHEAT_NAME}!$C:$C, ${TASK_SHEAT_NAME}!$J:$J,"<"&($A${currentRowNumber}+1), ${TASK_SHEAT_NAME}!$I:$I,"="&$B${currentRowNumber})` // 特定のmilestoneのうちdの日付まで完了したポイント合計 const total_closed_point = `=SUM($F${lastRowNumber+1}:$F${currentRowNumber})` // 予測線用の残りポイント(残日数の割合で計算) const focast_remaining_point = index == 0 ? `=$G${currentRowNumber}` : `=MAX(($I${currentRowNumber-1}+$E${currentRowNumber})-ROUNDUP(($I${currentRowNumber-1}+$E${currentRowNumber})/(${working_day_num}-$D${currentRowNumber})) * ($D${currentRowNumber}-$D${currentRowNumber-1}), 0)` // 残りポイント(未来の日付は空白を入力) const remaining_point = `=IF(TODAY()+1>A${currentRowNumber},MAX($G${currentRowNumber}-$H${currentRowNumber}, 0), "")` // スプレッドシートに書き込み activitySheet.appendRow([ Utilities.formatDate(d, "JST", "yyyy-MM-dd"), `Sprint${nextSprintNumber}`, is_business_day, sprint_elapsed_day, today_created_point, today_closed_point, total_created_point, total_closed_point, focast_remaining_point, remaining_point, ]) index += 1 } } 収集処理と同様にGASのトリガーにnextSprint関数を定期実行するように設定します。実行すると次のようなデータを書き込みます。 4. Looker Studioで表示する Sprint集計シート(下記の図ではdaily_activity)をLooker Studioのデータソースに追加して、折れ線グラフを作成します。フィルタ機能でmilestoneを設定することによりSprint単位に表示を絞ることができます。 設定を完了すると次のようなグラフが表示されます。 おわりに 今回はGAS + Looker Studioで簡易的なバーンダウンチャートを作る方法を紹介しました。バーンダウンチャートがあることで視覚的に進捗が把握しやすくなります。そして、スプリントゴールに間に合うかどうかを早めに見極めて、プロダクトオーナーへの相談や作業自体の見直しがしやすくなります。もしバーンダウンチャートを利用していない場合はぜひ導入を検討してみてください。 明日は、 @penpenpen さんの お金をかけずに学ぶRust です。お楽しみに! We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
はじめに こんにちは。セキュリティチームの添野隼矢と申します。 近年、サイバー攻撃によるセキュリティ被害やApache Log4jの脆弱性の件などで、脆弱性やシークレット情報をスキャンすることが重要になってきています。 本記事では、脆弱性やシークレット情報のスキャンを手軽に実行できるツール「Trivy」について紹介したいと思います。 「Trivy」とは Trivyとは、コンテナイメージやファイルシステム、RemoteのGit Repository等の脆弱性やシークレット情報をスキャンできるツールです。 Trivyは他の脆弱性スキャンツールと比べ、バイナリを配置するだけで利用可能になる等、導入が容易で、実行もワンライナーで実行することができます。 また、TrivyはGitHub Actions、JenkinsなどのCIにも簡単に組み込めるように作られています。 Trivyがスキャンできるターゲット Container Image Filesystem Git Repository (remote) Virtual Machine Image Kubernetes AWS 最近、AMIやEBSスナップショットのスキャンに対応しました。( Trivy Now Scans Amazon Machine Images (AMIs) ) 参考: https://github.com/aquasecurity/trivy#installation Trivyのスキャン内容 使用しているOSのソフトウェアの依存関係(SBOM) 既知の脆弱性(CVE) IaC の問題と設定ミス 機密情報とシークレット情報 ソフトウェア ライセンス 参考: https://github.com/aquasecurity/trivy#installation Trivyの脆弱性スキャンについて Trivyの脆弱性スキャンは、trivy-dbと呼ばれているツールで作成されている脆弱性DBを参照して行われます。 6時間おきに、脆弱性DBが更新されていくため、最新の脆弱性情報で脆弱性スキャンをかけることができます。 Update interval Every 6 hours 引用元:https://github.com/aquasecurity/trivy-db 上記の脆弱性DBに更新があった際、スキャンコマンド初回実行時に以下のようなコマンドが流れ、自動で最新の脆弱性情報を取り込んでくれます。 INFO Need to update DB INFO DB Repository: ghcr.io/aquasecurity/trivy-db INFO Downloading DB... 35.54 MiB / 35.54 MiB [---------------------------------------------------] 100.00% 2.33 MiB p/s 15s Trivy実際に使ってみる 初めにTrivyをインストールします。 インストール方法は以下の公式に従って、インストールをします。 https://github.com/aquasecurity/trivy#installation コンテナイメージスキャン コンテナイメージをスキャンする際は、 trivy image [image名] でスキャンすることができます。 試しにベースイメージ Alpine Linux 3.4のPythonイメージに対してスキャンしてみます。 ※出力結果は長いため、記載は一部のみにしています。 $ trivy image python:3.4-alpine python:3.4-alpine (alpine 3.9.2) Total: 37 (UNKNOWN: 0, LOW: 4, MEDIUM: 16, HIGH: 13, CRITICAL: 4) ┌──────────────┬────────────────┬──────────┬───────────────────┬───────────────┬──────────────────────────────────────────────────────────────┐ │ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │ Title │ ├──────────────┼────────────────┼──────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤ │ expat │ CVE-2018-20843 │ HIGH │ 2.2.6-r0 │ 2.2.7-r0 │ expat: large number of colons in input makes parser consume │ │ │ │ │ │ │ high amount... │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2018-20843 │ │ ├────────────────┤ │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ │ CVE-2019-15903 │ │ │ 2.2.7-r1 │ expat: heap-based buffer over-read via crafted XML input │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2019-15903 │ ├──────────────┼────────────────┼──────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤ │ libbz2 │ CVE-2019-12900 │ CRITICAL │ 1.0.6-r6 │ 1.0.6-r7 │ bzip2: out-of-bounds write in function BZ2_decompress │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2019-12900 │ ├──────────────┼────────────────┼──────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤ │ libcrypto1.1 │ CVE-2019-1543 │ HIGH │ 1.1.1a-r1 │ 1.1.1b-r1 │ openssl: ChaCha20-Poly1305 with long nonces │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2019-1543 │ │ ├────────────────┤ │ ├───────────────┼──────────────────────────────────────────────────────────────┤ 出力結果を確認すると37件の脆弱性があることがわかります。 37件の脆弱性の内訳にCRITICAL、HIGH、MEDIUM、LOW、UNKNOWNが書かれていると思います。 こちらは、共通脆弱性評価システムCVSS(Common Vulnerability Scoring System)によって評価された脆弱性のスコアをもとに設定されているものです。 現在、最新版のCVSSバージョン3での各レベルのスコアは以下の通りです。 CRITICAL(9.0~10.0)、HIGH(7.0~8.9)、MEDIUM(4.0~6.9)、LOW(0.1~3.9)、UNKNOWN(未確認) スコアの算出方法など詳しくは こちら をご覧ください。 --severity オプションを使用することで、上記の出力結果から脆弱性のレベルで絞ることもできます。 実行例(CRITICAL,HIGHで絞ってみた例) $ trivy image --severity CRITICAL,HIGH python:3.4-alpine python:3.4-alpine (alpine 3.9.2) Total: 17 (HIGH: 13, CRITICAL: 4) ┌──────────────┬────────────────┬──────────┬───────────────────┬───────────────┬──────────────────────────────────────────────────────────────┐ │ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │ Title │ ├──────────────┼────────────────┼──────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤ │ expat │ CVE-2018-20843 │ HIGH │ 2.2.6-r0 │ 2.2.7-r0 │ expat: large number of colons in input makes parser consume │ │ │ │ │ │ │ high amount... │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2018-20843 │ │ ├────────────────┤ │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ │ CVE-2019-15903 │ │ │ 2.2.7-r1 │ expat: heap-based buffer over-read via crafted XML input │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2019-15903 │ ├──────────────┼────────────────┼──────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤ │ libbz2 │ CVE-2019-12900 │ CRITICAL │ 1.0.6-r6 │ 1.0.6-r7 │ bzip2: out-of-bounds write in function BZ2_decompress │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2019-12900 │ ├──────────────┼────────────────┼──────────┼───────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤ ファイルシステムスキャン 次にファイルシステムをスキャンする際は、 trivy fs または trivy rootfs でスキャンすることができます。 trivy fs と trivy rootfs の違い fsコマンド ローカルプロジェクトに対するスキャン 参考: Filesystem roofsコマンド ホストマシン、仮想マシンイメージ、展開されたコンテナイメージのファイルシステムなどに対するスキャン 参考: Rootfs 試しにDjangoをインストールしたPythonの仮想環境のプロジェクトを用意してみました。 $ cat Pipfile [[source]] url = "https://pypi.org/simple" verify_ssl = true name = "pypi" [packages] django = "==4.1.4" [dev-packages] [requires] python_version = "3.8" 上記のプロジェクトに対して、スキャンしてみます。 ※実行すると、実行時刻が実行結果のINFOの左列に出ますが、ここでは消しています。 $ trivy fs /path/to/project/ INFO Vulnerability scanning is enabled INFO Secret scanning is enabled INFO If your scanning is slow, please try '--security-checks vuln' to disable secret scanning INFO Please see also https://aquasecurity.github.io/trivy/v0.35/docs/secret/scanning/#recommendation for faster secret detection INFO Number of language-specific files: 1 INFO Detecting pipenv vulnerabilities... 上記のようなコマンドが出力された後に、なにも脆弱性情報が出なかった場合は、スキャンで脆弱性が見当たらなかったということになります。 また単一ファイル(Pipfile.lockなど)に対してもスキャンすることが可能です。 $ trivy fs /path/to/project/Pipfile.lock INFO Vulnerability scanning is enabled INFO Secret scanning is enabled INFO If your scanning is slow, please try '--security-checks vuln' to disable secret scanning INFO Please see also https://aquasecurity.github.io/trivy/v0.35/docs/secret/scanning/#recommendation for faster secret detection INFO Number of language-specific files: 1 INFO Detecting pipenv vulnerabilities... ここで一時的にDjangoのバージョンを4.0.5(脆弱性が発見されているバージョン)に落としてみて、再度スキャンをしてみます。 $ pipenv install django==4.0.5 $ cat Pipfile [[source]] url = "https://pypi.org/simple" verify_ssl = true name = "pypi" [packages] django = "==4.0.5" [dev-packages] [requires] python_version = "3.8" $ trivy fs /path/to/project/Pipfile.lock INFO Vulnerability scanning is enabled INFO Secret scanning is enabled INFO If your scanning is slow, please try '--security-checks vuln' to disable secret scanning INFO Please see also https://aquasecurity.github.io/trivy/v0.35/docs/secret/scanning/#recommendation for faster secret detection INFO Number of language-specific files: 1 INFO Detecting pipenv vulnerabilities... Pipfile.lock (pipenv) Total: 3 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 2, CRITICAL: 1) ┌─────────┬────────────────┬──────────┬───────────────────┬──────────────────────┬─────────────────────────────────────────────────────────────┐ │ Library │ Vulnerability │ Severity │ Installed Version │ Fixed Version │ Title │ ├─────────┼────────────────┼──────────┼───────────────────┼──────────────────────┼─────────────────────────────────────────────────────────────┤ │ django │ CVE-2022-34265 │ CRITICAL │ 4.0.5 │ 3.2.14, 4.0.6 │ python-django: Potential SQL injection via Trunc(kind) and │ │ │ │ │ │ │ Extract(lookup_name) arguments │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2022-34265 │ │ ├────────────────┼──────────┤ ├──────────────────────┼─────────────────────────────────────────────────────────────┤ │ │ CVE-2022-36359 │ HIGH │ │ 3.2.15, 4.0.7 │ An issue was discovered in the HTTP FileResponse class in │ │ │ │ │ │ │ Django 3.2... │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2022-36359 │ │ ├────────────────┤ │ ├──────────────────────┼─────────────────────────────────────────────────────────────┤ │ │ CVE-2022-41323 │ │ │ 3.2.16, 4.0.8, 4.1.2 │ python-django: Potential denial-of-service vulnerability in │ │ │ │ │ │ │ internationalized URLs │ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2022-41323 │ └─────────┴────────────────┴──────────┴───────────────────┴──────────────────────┴───────────────────────────────────────────────────────────── 4.0.5にバージョンを下げてスキャンしたところ、3件の脆弱性が検知されました。 3件の脆弱性が検知されることの確認が終わりましたので、バージョンを元に戻します。 $ pipenv install django==4.1.4 終わりに Trivyには、ライセンスチェックやシークレット情報のチェック、出力形式の指定、また記事の最初の方で触れたAWSのAMIやEBSスナップショットのスキャンなど、本記事で紹介していない部分がまだまだあります。 今後も引き続き紹介していければと思います。 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
この記事は、 ニフティグループ Advent Calendar 2022  22日目の記事です。 こんにちは!ニフティ株式会社の上原です。 個人的にRustという言語にハマっており、社内でもRustを学ぶ勉強会を主催しています。 プログラミング言語を学習する際にはまずは書籍を買って勉強される方も多いと思うのですが、お金がかかってしまいます。 Rustは入門者向けのWeb上の資料が充実しているため、書籍を買わずとも学習することができます。 ここでは無料で見られる教材についていくつかご紹介します。 教材 Tour of Rust Rustで出てくる概念の説明とソースコードが例示され、実際にコードを手元で実行することができる教材です。 私はいきなりThe Rust Programming Language(後述)から始めてしまったのですが、まずはここから始める方が良かったかなぁと思いました。 ローカルにRustの開発環境を整えることなく、ブラウザ一つあれば気軽に学習を進めることができることから、まずはTour of Rustからやるのがいいと思います。 Tour of Rustの変数についてのページ。概念の説明とコード例が示され、コードを実行することもできる。 https://tourofrust.com/00_ja.html The Rust Programming Language 公式から出ているRustの入門書で、よくTRPLと呼ばれています。 入門書と言われてはいますがカバーしている範囲は幅広く、この教材だけでRustのなんたるかは理解できるかと思います。 その分、インプットする量は多いのですがコマンドラインツールやWebサーバを作る回もあるので、実際に手を動かしながら学ぶこともできます。 また、メモリやHTTPなどのRust以外の話にも触れられており、参考になります。 難点としては、とても丁寧に文章が書かれていて、結局何を言いたいのかわからなくなってしまうことがあったり、日本語訳がところどころおかしな場所もあったりするところです。 そういう時は他のネットの記事を読んでみたり、原文の英語で読むと理解が進むかもしれません。 大体11章までやればRustの基本的な部分は抑えられると思います。 ちなみに社内勉強会でもTRPLを使っており、全て終わるのに1年かかるくらい濃密な内容となっています。 Ther Rust Programming Languageのページの一例。懇切丁寧に説明してくれる。 コード例に現れるferrisという蟹のキャラクターが可愛い。 https://doc.rust-jp.rs/book-ja/ Rust By Example TRPLと異なりこちらはコード中心の教材になります。例示されたコードを通してRustの各概念を学んでいきます。 TRPLで勉強した部分を簡単におさらいしたいときに使っています。 Rust By Exampleの所有権とムーブについて説明しているページ。コード例を見て学ぶことができる。 https://doc.rust-jp.rs/rust-by-example-ja/ rustlings Rustの文法練習問題集です。 そのままではコンパイルできないコードが幾つか用意されており、コンパイルできるようにソースコードを修正していく中でRustの諸概念を習得することができます。 これだけだと他の言語の教材にもあるかもしれませんが、rustlingsというコマンドをインストールしてやると進捗状況や正解かどうかが で表示されるのでゲーム感覚で楽しく学ぶことができるのでおすすめです。 TRPLの章を終わらせるごとにrustlingsで力試しすると理解がさらに深まります。 始め方は、rustlingsというディレクトリができてもいいディレクトリで以下のコマンドを実行します。 $ curl -L https://raw.githubusercontent.com/rust-lang/rustlings/main/install.sh | bash エディタでrust-analyzerによる補完を効かせたい方は以下のコマンドを実行しておきましょう。 $ cd rustlings $ rustlings lsp rustlings watchでスタートです $ cd rustlings $ rustlings watch rustlingsをしている様子。rustlings watchを叩けばあとは指示通り進めていくだけ。 https://github.com/rust-lang/rustlings まとめ 現在はネットでも十分に情報を集め、無料でプログラミング言語を学べる時代になりました。 2023年を迎えるにあたり新しいことを始めるのに、Rustの学習を一つ候補に入れていただき、この記事を参考にいただければ幸いです。 明日は @kinari321 さんです!楽しみですね。 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
こんにちわ。11/22にニフティ初のオンラインイベント「NIFTY Tech Day 2022」を開催しました。皆様ご覧いただけましたでしょうか? 今回は、当日の内容について紹介します。 概要 サービス開始から35年、ニフティは常に技術者やIT技術と共にありました。 高い好奇心で新しい技術トレンドをいち早く導入し、システムやサービスへの活用を常に模索しています。ニフティが創業時から変わらないのは、好奇心と挑戦し続けること。 この”NIFTY Tech Day”を通じて、私たちの今と未来をお伝えします。 公式サイト 今回のタイムテーブルとセッション紹介を見ることができます。 また謎解きページもあるのでチャレンジしてみてください! ↑ロゴとサイトはテクニカル・SF・近未来感をイメージして社内の制作チームが作成 セッション 各セッションごとに紹介文と動画を載せていますので見逃した方もう一度見たい方はぜひチェックしてください! 最近のインターネット動向と未来のネットワークについて ニフティのビジネスパートナー NTTコミュニケーションズ株式会社の森信様 をお招きした特別セッションです。最近のインターネット動向、そしてまったく新しい未来の技術までご紹介いただきました。 スクラムのハードルの越え方〜リリース数を1.5倍にするまで〜 デブサミウーマン 登壇者による「スクラムのハードルの越え方」についてのセッションです。 社内8チームに対しスクラム導入を支援してきた中でわかったことについて紹介しました。 複数プロダクトを抱えて行うスクラム開発のこれまでとこれから 持っているプロダクトが複数あってPOも複数必要。スクラム開発のアンチパターンのようにも見えますがコミュニケーションで解決できました。 それまでの道のりについて紹介しました。 クリーンアーキテクチャはこの3年間で私たちのチームに何をもたらしたのか ニフティのオプションサービスの設計開発にクリーンアーキテクチャを導入しました。私たちだからこそ語れるクリーンアーキテクチャのあれこれを実体験を交えて紹介しました。 なぜニフティはLeSSを選んだのか。 @nifty MAX光における大規模スクラム開発体験談 ニフティの新規接続サービス @nifty MAX光 は大規模スクラムLeSSで作られました。開発の裏側についてパネルディスカッション形式で語りました。 AWS/GCPとSlackを駆使したオフィスの固定電話廃止への取り組み オフィスの固定電話を廃止するため、AWS/GCPとSlackを駆使して、サーバレスな自動電話音声応答システムを開発した話を若手エンジニアがカジュアルに紹介しました。 オンライン会議を盛り上げる!音声リアクションツール「もじこえ」の開発 ニフティには技術を学べる環境、便利ツールを作ってみるという文化があります。 作成したオンライン会議のリアクション不足を解決するツールを例にツールを作るときの考え方や構成について紹介しました。 安心・安全な@niftyメールサービスの裏側 ニフティでは @nifty メールサービス を提供しています。 本セッションでは、お客様に安心してサービスをご利用いただくための取り組みについて紹介しました。 セシール事業におけるモノリシックアーキテクチャとの向き合い方 「ビジネスを加速させたい」 その思いを実現するためには、さまざまな課題があります。 本セッションでは、そのような課題に対して、セシールがどう向き合っていったかを紹介しました。 CI/CDを導入して変わったモバイルアプリ開発体制 ニフティのグループ会社であるニフティライフスタイル株式会社のセッションです。 CI/CDツールの導入による「モバイルアプリ開発の効率化」について紹介しました。 深層自然言語処理によるニュース記事要約の手法と実装 ニフティのスペシャリスト制度である N1! の機械学習エンジニアが自然言語処理について解説。 本セッションではニフティニュースにおける記事要約や構築した深層自然言語処理モデル、AWSを用いた実際のサービス実装について紹介しました。 ニフティエンジニア徹底分析 休憩中に流していたニフティエンジニアのアンケート結果を少しだけお見せします。 開発・運用どちらも担当している人が多いんです! ニフティではサービスによって様々なプログラミング言語が使用されています。 最近では社内でRustの勉強会も行われていました! ほとんどのニフティエンジニアは新しい技術に「 関心があり 」と回答しました。 導入した結果を紹介しているセッションもありますのでぜひチェックしてみてください! 様々な特徴が出ました。仕事に対する姿勢を表したワードが目立ちますね。 こんなアンケートもありました! (ちなみに私はきのこ派です。) 技術者交流会 技術者交流会とは「技術者同士で喋る」ことを目的とした懇親会です。 社内のエンジニアとTech Day 2022にご参加いただいた方とオンラインで技術者交流会を実施しました。 ニフティでは、技術者との交流を推進しており、その活動の一環として開催が決定しました。 Tech Day 2022の登壇者のAsk the Speakerが行われたり、技術に関連する話で盛り上がったりと参加された方からは好評でした。 ↑Remoで作成した会場の様子 まとめ NIFTY Tech Day 2022 では、ニフティのサービス・技術を紹介してきました。 配信を通して少しでもニフティの魅力を知ってもらえますと幸いです。 またクロージングで話がありましたが、来年秋に NIFTY Tech Day 2023 を開催いたします。 そちらもぜひご期待ください! ニフティグループでは一緒に働く仲間を募集中です ニフティ株式会社 新卒採用、キャリア採用を実施しています。ぜひ リクルートサイト をご覧ください。 ニフティエンジニアが業務で学んだことやイベント情報を エンジニアブログ にて発信しています! ニフティライフスタイル株式会社 想像以上を、みつけよう。 をコーポレートメッセージに、“一人ひとり”のライフスタイルを便利で豊かにするため、ニフティライフスタイルのエンジニアは日々開発をしています。 採用情報 や ニフティライフスタイル Tech Blog をご覧ください。 株式会社セシール セシールでは「ECシステムの新規開発」「基幹システム開発・運用」「インフラ設計構築~運用」についてキャリア採用を行っています。 詳しくは キャリア採用募集要項 をご覧ください。 ニフティエンジニアのTwitterアカウントを作りました NIFTY Tech Talkのことや、ニフティのエンジニアの活動を発信していきます。 https://twitter.com/NIFTYDevelopers
アバター
この記事は、 ニフティグループ Advent Calendar 2022 18日目の記事です。 はじめに 基幹システムグループ サービスインフラチームの南川です。 普段はユーザーサインアップやシングルサインオン、顧客管理システム等の開発や運用を担当しています。 今回は、 Amazon CloudWatch Logs に出力するログの形式について説明します。 Amazon CloudWatch Logs Amazon CloudWatch Logs は、 AWS リソースや AWS 上で実行するアプリケーションからのログファイルをモニタリング、保存、アクセスできるサービスです。 CloudWatch Logs に出力するログ形式 結論から言うと JSON 形式 にしておくと、検索や分析する際に楽です。 今回はチャットツールのログを例として、ログが JSON 形式でない場合と JSON 形式である場合でどのような違いがあるかを取り上げます。 例:チャットツールのログ (JSON 形式でない場合) まず、ログとして出力されるデータは以下のようになっています。 先頭にログのレベル、その後ろにログのメッセージが記載されています。 [INFO] Taro posted "hoge" from 1.2.3.4 [INFO] Jiro posted "fuga" from 5.6.7.8 [INFO] Taro posted "piyo" from 1.2.3.4 [INFO] Jiro posted "hello" from 9.10.11.12 [ERROR] Saburo couldn't post from 1.1.1.1 投稿成功時 (1-4行目) INFO レベルで「<ユーザー名> posted “<本文>” from <IPアドレス>」 エラー発生時 (5行目) ERROR レベルで「<ユーザー名> <エラーメッセージ> from <IPアドレス>」 CloudWatch Logs にこれらのログを出力すると以下のようになります。 CloudWatch Logs に出力されたこれらのログは、 CloudWatch Logs Insights を用いて検索・分析することができます。 CloudWatch Logs Insights でログを検索、分析する CloudWatch Logs Insights では、クエリを使ってログデータを検索・分析・データの抽出ができます。 それでは、先ほどの CloudWatch Logs に出力したログに対して、 CloudWatch Logs Insights でデータを抽出してみます。 例:ログイベントを取得する まずは、簡単な例として最新20件のログイベントのタイムスタンプとメッセージを新しい順で取得するクエリを実行します。クエリとその実行結果は以下の通りです。 fields @timestamp, @message | sort @timestamp desc | limit 20 下部の実行結果にはCloudWatch Logsに出力されたログのタイムスタンプとメッセージが表示されています。クエリは複数のコマンドで構成されており、それぞれのコマンドがパイプ文字 ( | ) で区切られています。クエリ内で使えるコマンドについては、 CloudWatch Logs Insights のクエリ構文 を参照してください。今回はこのクエリで使われているコマンドについて簡単に説明します。 fields @timestamp, @message 1行目の fields コマンドは、クエリ結果の特定のフィールドを表示するコマンドです。例のクエリでは @timestamp と @message の値をクエリ結果に表示するようにしています。このコマンドで指定できるフィールドは サポートされるログと検出されるフィールド を参照してください。 sort @timestamp desc 2行目の sort コマンドは、特定のフィールドについてソートするコマンドです。例のクエリでは @timestamp について降順 (desc) ソートしています。 limit 20 3行目の limit コマンドは、クエリで返すログイベントの上限数を指定するコマンドです。例のクエリでは検索結果 (ログイベントの数) を20件まで返すように指定しています。 例:投稿に成功したログイベントの投稿者名とメッセージとIPアドレスを取得する 次に、投稿に成功したイベントを抽出し、そのイベントの投稿者名とメッセージとIPアドレスを表示するクエリを実行します。クエリとその実行結果は以下の通りです。 投稿者名、メッセージ、IPアドレスはそれぞれ userName, body, ipAddress フィールドに格納されています。 fields @message | parse @message "[*] *" as loggingType, loggingMessage | filter loggingType = "INFO" | filter loggingMessage like /\w+ posted ".*" from \d+\.\d+\.\d+\.\d+/ | parse loggingMessage "* posted \"*\" from *" as userName, body, ipAddress | display @message, loggingType, loggingMessage, userName, body, ipAddress parse @message "[*] *" as loggingType, loggingMessage parse loggingMessage "* posted \"*\" from *" as userName, body, ipAddress 2,5行目の parse コマンドは、フィールドの値からでデータを抽出し、クエリ (後続のコマンド) で使える一時的なフィールドを作成するコマンドです。例の2行目のクエリでは、 @message において "[*] *" の各 * に該当する値が as の後ろの各フィールド (1つ目の * の箇所は loggingType 、2つ目の * の箇所は loggingMessage) に格納されます。例えば、 @message が 「 [INFO] Taro posted "hoge" from 1.2.3.4 」 の場合、 loggingType は 「 INFO 」 、 loggingMessage は 「 Taro posted "hoge" from 1.2.3.4 」 となります。 filter loggingType = "INFO" filter loggingMessage like /\w+ posted ".*" from \d+\.\d+\.\d+\.\d+/ 3,4行目の filter コマンドは、1つ以上の条件を満たすイベントを取得するコマンドです。例のクエリでは、 loggingType の値が “INFO” であるイベント(3行目)と、 loggingMessage の値が 「 \w+ posted ".*" from \d+\.\d+\.\d+\.\d+ 」 というパターンの正規表現にマッチしているイベント(4行目)を取得しています(4行目の filter コマンドが無くても投稿成功イベントを抽出できなくはないですが、正規表現でも判定できる例として追加しています)。 display @message, loggingType, loggingMessage, userName, body, ipAddress 5行目の display コマンドは、クエリ結果の特定のフィールドを表示するコマンドです。例のクエリではカンマ区切りで列挙されたフィールド (@message, loggingType, loggingMessage, userName, body, ipAddress) の値を表示しています。 このように、ログを構造化 (JSON 形式で記述) していない場合、ログから投稿文、投稿者名、IPアドレスを抽出するために5,6行程度のクエリを書く必要があります。 ログを JSON 形式で構造化する 先ほどのログを JSON 形式で構造化してみます。構造化した一例は以下の通りです。 {"level":"INFO","user_name":"Taro","body":"hoge","ip_address":"1.2.3.4"} {"level":"INFO","user_name":"Jiro","body":"fuga","ip_address":"5.6.7.8"} {"level":"INFO","user_name":"Taro","body":"piyo","ip_address":"1.2.3.4"} {"level":"INFO","user_name":"Jiro","body":"hello","ip_address":"9.10.11.12"} {"level":"ERROR","user_name":"Saburo","body":"couldn't post","ip_address":"1.1.1.1"} CloudWatch Logs にこれらのログを出力すると以下のようになります。 この構造化されたログから、先ほどと同様の投稿者名とメッセージとIPアドレスを取得するクエリを書くと以下のようになります。投稿者名、メッセージ、IPアドレスはそれぞれ user_name, body, ip_address フィールドに格納されています。 fields @message, level, user_name, body, ip_address | filter level = "INFO" ログの本文 (@message) が JSON 形式である場合、 parse コマンド不要で JSON フィールドの値を、キー名を指定して参照することができます。これにより、構造化されていない時に比べ、クエリの行数を削減することができました。 また、ネストが深く複雑な JSON でも、ドット表記を使用して JSON フィールドにアクセスすることも可能です。 参考: サポートされるログと検出されるフィールド – Amazon CloudWatch Logs 構造化ログのデメリット ログを JSON 形式で出力する場合、構造化されていない時に比べ、ログのサイズが大きくなる傾向があります。また、 CloudWatch Logs では、ログの取り込み、保管、分析によって 1 GB ごとに料金が発生します。つまり、 JSON 形式でログ出力すると、 JSON形式で出力されていない時に比べて、 CloudWatch Logs のコストがかかることがあります。 JSON 形式でログを出力する際は、すべてのデータをログ出力するのではなく、必要なデータを取捨選択するなどの工夫が必要です。 おわりに 今回は CloudWatch Logs のログ形式について書きました。CloudWatch Logs に格納するログは JSON 形式 (構造化ログ) にしたほうが、 Logs Insights のクエリが簡潔になり、検索・分析・データ抽出が楽になります。しかし、ログサイズの肥大化に伴い、コストが増えることもあるので、不必要なデータは出力しないなどの工夫が必要です。皆さんも AWS 上にアプリケーションをデプロイする際は、ログ形式を JSON にすることを検討してみてください。 明日は、 yt_glaceon さんの担当です。 お楽しみに! 参考 Amazon CloudWatch Logs とは – Amazon CloudWatch Logs CloudWatch Logs Insights を使用したログデータの分析 – Amazon CloudWatch Logs サポートされるログと検出されるフィールド – Amazon CloudWatch Logs CloudWatch Logs Insights のクエリ構文 – Amazon CloudWatch Logs Amazon CloudWatch Pricing – Amazon Web Services (AWS) CloudWatch の料金を理解して今後の料金を削減する We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
この記事は、 ニフティグループ Advent Calendar 2022 17日目の記事です。 こんにちは、会員システムグループのたけろいどです。アドベントカレンダーも中盤になってきました。一年が早い… 概要 本記事ではエンジニアチームとデザインチームの連携について詳しく書いていきます。 ニフティでサービス開発する際はエンジニアチーム・デザインチーム・企画チームが協力してサービスを作り上げています。 しかし協力といっても当時は頻繁にコミュニケーションをとれていませんでした。とくにエンジニアチームとデザインチームのコミュニケーションは少なかったです。 その状態を改善するために行なっていることを紹介します! いままでの流れ まずはいままでのサービス開発の流れを紹介します。 従来は企画チームがデザイナーチームに依頼してデザイン作成を行い、その後にエンジニアチームがデザインを見ながら開発をする流れでした。 図にするとこうなります。これを便宜上、旧フローと呼びます 旧フローのメリット 企画チームが進捗把握しやすい エンジニア視点では1対1の関係でフローがわかりやすい 旧フローのデメリット 小さな修正でも企画を通すためリリースまでのスピードが落ちる 高機能なサービス開発には不向き HTML・CSS・JSでできることは数年前の比ではありません。リッチなWEBページを作ることも多くなり、確認事項はどんどん増えていました。その状況下で旧フローだと認識の齟齬が発生しやすくサービス提供までのスピードが落ちてしまうのは自明でした。 これからの流れ そこでそのフローに手を加えることにしました。 デザイナー・エンジニアが一緒になって作る体制を整え、そこにデザイン依頼を投げてもらう形です。ここでは新フローと呼びます。 新フローのメリット デザインにエンジニア視点が入り、振る舞いについて理解が深まる デザイナーの意図を理解した上で開発に取り組める 新フローのデメリット 企画が進捗把握しづらい デザイナーとエンジニアの関心ごとが異なりコミュニケーションがうまくいかない 他にも細かな課題は残されていますが、基本的にはいままでのコミュニケーション不足からなるものです。次のセクションではデメリットとどう向き合っているか書いていきます。 デメリット向き合い方 主にデザイナー・エンジニアの会話をふやすことを目標にしています。企画とはスクラム開発を通じて進捗確認など行なっています。それぞれ詳しく書いていきます。 スクラム開発 スクラム開発はスプリントレビューがあるため進捗を細かい単位で確認できます。またスプリントレビューはコミュニケーションの場でもあります。作ったものを見ながらレビューするため互いの認識を合わせるにとても役に立ちます。 特にサービス開発始めは文言や動作などで細かな齟齬が多発しがちです。それをタスク化し修正を都度行っていけるスクラム開発はエンジニアとしても助かっています。 スクラム開発をしていくことでコミュニケーションが増え、より良いサービス開発ができています。 フロントエンド知見共有会 ニフティではフロントエンド知見共有会という社内勉強会を毎週開催しています。フロントエンドに興味のある人が集まり、自由にLT・雑談をするという会です。当初はエンジニアが集まりフロントエンドについてエンジニア同士でお話しをしていました。 この場にデザイナーの方を招くことで知見を共有してもらい互いのことを知ってもらうことができています。先日はカラーユニバーサルの話題でした。ニフティでもカラーユニバーサルを意識していることやそれをデザインに落とし込む時の注意点などを教えてもらいました。 互いの価値観について知る場としてとても有効に働いています。 デザインシステム輪読会 現在は企画段階ですがデザインシステム輪読会を開こうと考えてます。 こちら の本を輪読しデザイナー・エンジニアともに同じ知識をつけていこうという狙いです。 またデザインシステムは効率の良いデザインを作成できるだけでなく会社のブランディングを高めることもできます。こういった活動をデザイナー・エンジニア共にしてコミュニケーションを深めていきたいと思ってます。 デザインシステム輪読会の活動は別のブログで書いていこうかなと思います。(次回予告 まとめ サービス開発でのエンジニアがどうデザイナー・企画と連携しフロントエンド改善に向かっているのか書きました。 まずは密にコミュニケーションをとり、互いにリスペクトし合える環境を整えていっています。まだまだ課題は山積みですがお客様目線のサービスを素早く提供できるように頑張っています。 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering 明日は、 @mh36 さんの記事です。 Amazon Cloud WatchLogsのお話しみたいです。楽しみです!
アバター
この記事は、 ニフティグループ Advent Calendar 2022  16日目の記事です。 初めに 最近、アニメを見る欲が復活した会員システムグループの2年目社員の関です。 数ヶ月前に以下のブログ記事を書きました。 主催した社内勉強会の課題でアクセシビリティ的に優れているTODOリストの課題を出した話 上記はアクセシビリティ的に良いTODOリストを勉強会の課題として出したという記事でした。 その勉強会の最終課題としてアクセシビリティに優れたツールバーの課題を出したので今回はその話をしようと思います。 勉強会については前回のブログ記事に記述してあるのでそちらを確認ください。 勉強会の 最終課題の概要 早速、勉強会の最終課題について説明します。 課題の概要は以下になります。 課題 アクセシブルなツールバーとリッチテキストボックスを作成する 要件 以下の要件を満たすツールバーとテキストボックスを作成してください 選択した文字の色を変えるボタン(任意の色で良い) 選択した文字を太文字に変更するボタン 選択した文字をコピーするボタン 文字をペーストするボタン 選択した文字をカットするボタン イメージ その他 React、Vue、Svelte、Solid.jsなどのライブラリは使っても良いが、React-modalなどのライブラリは使わずに自分で実装を行ってください 上記が課題の概要となります。 このようなツールバーはGitHubのIssueのテキストボックスやNotionなど多数のウェブアプリケーションで使用されています。 一方で、さまざまなユーザに使いやすいように作るにはさまざまな工夫が必要になります。 それではこの課題をどのようにアクセシブルに作成するかを見ていきます。 アクセシビリティの観点から気をつけるべき点 さて上記の課題に対して、どのようなことを気をつければ良いのかを以下で述べます。 Toolbarとして認識されるようにHTMLを作成する ツールバーはHTML要素としては存在していません。そのため、WAI-ARIAなどの技術を使用してToolbarとして認識されるようにHTMLを作成する必要があります。 具体的には以下を満たすように作成すると良いです。 ToolbarのRole要素が入っている 以下のように role 要素を使用して記述を行うことで、ブラウザにツールバーであることを認知してもらい、それを使用者に伝えることができます。 <div class="toolbar" role="toolbar" aria-controls="textarea-sent"> ... </div> Toolbarには aria-label によってそのツールバーの意味が付与されている そのツールが何をするものなのか、支援ツールを介して使用者に伝えることができます。 例えば、以下の例ではtoolGroupごとにラベルを指定しています。 <div class="toolGroup" :aria-label="スタイルの変更をする"> ... </div> Toolbarに aria-controls で操作対象が指定されている そのツールバーがどのコンテンツを操作するものなのか、ブラウザに伝えるとそれが支援技術(VoiceOverなど)に伝わり、多くのユーザにも使いやすいツールバーになります。 <div class="toolbar" role="toolbar" aria-controls="textarea-sent"> ... </div> <div ... id="textarea-sent" ... > </div> toolbar内のボタンは button 要素で正しく記述されている button要素を使用することで、そのツール要素がボタン操作できることをブラウザに伝えることができます。こうすることで、さまざまなユーザがボタンの認識がしやすくなり、ツールバーを使いやすくなります。 <button class="tool tooltip" :area-pressed="isPressed" :area-disabled="isDisabled" @click="props.onClick(isDisabled)"> ... </button> 以上のようにツールバーを作成することで、支援技術がツールバーをツールバーとして認識するようにHTMLを作成することができます。 操作対象などを指定することで、音声でWebページを操作するユーザやキーボード操作するユーザにもツールバーの操作がしやすいようにすることが可能になります。 キーボード操作 HTMLとWAI-ARIAを使用してWebページを構築するだけではなく、JavaScriptなどを使ってキーボード操作しやすいようにするのも重要です。 キーボード操作を行う一定数のユーザはTabで操作を行いますが、ツールバー要素全てをTabでフォーカス可能にすると辿り着きたい要素まで時間がかかってしまいます。 そのため、矢印でツールバーの要素を操作できるようにし、タブではツールバーの前後の要素に移動するようになどを実装するとキーボードで操作するユーザにも使いやすい作りになります。 実際に作成する操作は以下になります。 HOMEを押すとToolbarの一番初めのToolに移動する Endを押すとToolbarの最後のToolに移動する 左矢印を押すと左のToolに移動する 右矢印を押すと右のToolに移動する ツールバーに戻ると前にフォーカスがあった場所にフォーカスされる 最初の場合は左で最後に、最後の場合は右で最初にフォーカスが移る 実際の実装は以下のようになります。 const changeTool = (event) => { const elements = document.getElementsByClassName('tooltip') const index = [].findIndex.call(elements, e => e === event.target) const moveFocus = (nowIndex:number, afterIndex: number) => { opacity.value = 1 changeTabindex(nowIndex, - 1); elements[afterIndex].focus() changeTabindex(afterIndex, 0); } switch(event.key){ case "ArrowLeft": // 矢印キーが押されたら、フォーカスとtabindexを変更する。 if(!elements[index - 1]) { moveFocus(index, elements.length - 1) } else { moveFocus(index, index - 1) } break; case "ArrowRight": if(!elements[index + 1]) { moveFocus(index, 0) }else { moveFocus(index, index + 1) } break; case "Home": moveFocus(index, 0) break; case "End": moveFocus(index, elements.length - 1) break; case "Escape": console.log("event", opacity.value) opacity.value = 0 break; } } // tabindexを変更する関数。 const changeTabindex = (index:number, tabindex:number) => { let indexTmp = index; for(let tool of toolBarList.value){ if(indexTmp < tool.tooltipsGroupList.length){ let tooltmp = tool.tooltipsGroupList[indexTmp] tooltmp.tabindex = tabindex tool.tooltipsGroupList.splice(indexTmp, 1, tooltmp); return } indexTmp -= tool.tooltipsGroupList.length; } } changeTool でイベントを受け取り、イベントの内容に合わせて moveFocus 関数を使用してフォーカスを移動します。 moveFocus 関数内では changeTabindex という関数を読んでおり、 changeTabindex 関数がDOM要素の tabindex を変更することでフォーカスを受け取れる対象を操作しています。 tabindexとは tabindexは要素が入力フォーカスを持てることとキーボードナビゲーションに加わるかどうかを指定できるHTMLのグローバル属性です https://developer.mozilla.org/ja/docs/Web/HTML/Global_attributes/tabindex こうすることで、ユーザがツールバーの外に移動した後にツールバーに戻ってきたときに、前操作していたボタンにフォーカスが当たるようになります。 音声 で伝える 音声を使用してウェブ操作をするユーザがツールバーを使用するために、アラートなどを使用して操作の内容を伝えることは重要です。 例えば以下の実装のようにすることでカット操作などをユーザに伝えることが可能です。 <!-- roleとしてdivをtextarea要素にする --> <div role="textarea" ... > </div> <!-- アラートするために以下のようなspan要素を透明で作成して、アラートされるようにする --> <span role="alert" aria-live="assertive" class="alert">{{alertText}}</span> // JavaScriptなどでは以下のようにalertTextを書き換えてアラートする alertText.value = `${selectText.value}をカットしました` 様々な色覚特性に適応した配色にする 筆者である私は色弱持ちですが、色のコントラストがはっきりしていないと色が正しく認識できないことが本当に多いです。 私のような色覚に障害がある方々のために色のコントラストをはっきりさせることはとても重要です。 Googleの開発者ツールを使用すると以下のようにコントラストの確認が可能です。 操作の可不可 を伝える ユーザが操作に対して可能か不可能かを認知できるように、ツールバーにDisable属性を設けて押せないようにすると親切です。また、CSSでそのことが見分けがつくように色の変更を行います。 isDisabledなどを用意してHTMLのプロパティを書き換え、操作が可能かどうかを切り替えます。また、こうすることで音声で操作をしているユーザにも操作が不可能かどうかを伝えることができます。 以下はVueで行う例です。属性要素を状態として保持しておきます。 const tooltipsListOfFixStyle = ref([ { ... //以下のように属性を用意します areaPropaties: { isPressed: false, isDisabled: true } }, ... ]) 次に以下のような関数を用意して操作します。 const disableFunc = async () => { const text = await navigator.clipboard.readText() if(selectText.value === '') { const templist = tooltipsListOfCCP.value.map((item)=>{ if(item.iconName === "paste" && text !== '') return item item.areaPropaties.isDisabled = true return item }) const tempFixList = tooltipsListOfFixStyle.value.map((item) => { item.areaPropaties.isDisabled = true return item }) tooltipsListOfCCP.value = templist tooltipsListOfFixStyle.value = tempFixList }else{ const templist = tooltipsListOfCCP.value.map((item)=>{ if(item.iconName === "paste" && text === '') return item item.areaPropaties.isDisabled = false return item }) const tempFixList = tooltipsListOfFixStyle.value.map((item) => { item.areaPropaties.isDisabled = false return item }) tooltipsListOfCCP.value = templist tooltipsListOfFixStyle.value = tempFixList } } 上記の関数では クリップボードにテキストがない場合はペーストボタンをdisableにする 文字が選択されていないときはカットとコピーボタンをdisableにする という二つの処理を行なっています。 tooltipの表示 ツールバーの要素はアイコンで作成されていますが、アイコンだけだとユーザの文化の違いや慣れの違いなどから意味がわからない可能性があります。そのため、操作名を表示するようにすることでツールバーのボタン操作が文字でわかるようにすると良いです。 ツールチップは以下のように span 要素などでHTMLで記述し、CSSでフォーカス時などに表示されるようにします。 <button class="tool tooltip" :tabindex="buttonTabIndex" :area-pressed="isPressed" :area-disabled="isDisabled" @click="props.onClick(isDisabled)"> <slot></slot> </button> <!--以下にツールチップのテキストを入れる--> <span class="tooltip-text">{{tooltipText}}</span> .tooltip-text { ... } /* ホバー時にツールチップの非表示を解除 */ .tool:hover + .tooltip-text { opacity: 1; visibility: visible; } .tool:focus + .tooltip-text { opacity: v-bind(opacity); visibility: visible; } まとめ アクセスフルなツールバーとリッチテキストボックスを作成するときに気をつけなければいけない点を紹介しました。 これ以外にも スマートフォンや低スペックなPCなどのユーザの機器の違い 言語の違い 文化の違い などなど、使うユーザによって、もっと考慮する点はあるように思えます。 これからもアクセシビリティについてもっと学んで、全てのユーザが使いやすいシステムを構築できるようなエンジニアになれるように頑張っていきたいです。 明日は、 @takenokoroid さんの記事です。 お楽しみに!! We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
この記事は、 ニフティグループ Advent Calendar 2022 14日目の記事です。 今回は、モブプロ支援ツールの「mob」について紹介していきたいと思います! モブプロとは? モブプログラミング(モブプロ)とは、複数人の開発メンバー(モブ)がコミュニケーションを取りながら実装を進め、実際にコードを書く「タイピスト(ドライバー)」と、「その他のモブ(ナビゲーター)」に分かれて行うソフトウェア開発手法です。また、タイピストは一定時間で交代することが良いとされています。 モブプロのメリットとしては以下のようなものが挙げられます。 一人で考え込まず、参加者と知識を共有し合うことで、素早く問題を解決できる 複数人で確認し合いながら作業を進めるため、レビューの時間を短縮できる 個人でそれぞれ持つ知見を他の参加者に共有でき、全員が共通認識を持って作業することができる。誰か一人がプロジェクトを抜けてしまっても、他の人がカバーすることができる コミュニケーションが促進される 反対に、デメリットは以下です。 大勢でのモブプロの場合、発言する回数が少なくなり集中が続かない 全員で一つのタスクについて作業をするため、リソース効率は落ちる 全員の予定を合わせるのが難しい モブプロの概要やメリット・デメリットを踏まえた上で、モブプロ支援ツールである「mob」について触れていきたいと思います。 mobとは? リポジトリ → https://github.com/remotemobprogramming/mob mobとはモブプロをリモートで実施する上で便利に使えるツールです。 実装されているコマンド群を使って、以下のようなことができます! タイマーを使った時間管理 Git経由で次の人へソースコードを渡すことができる(Git Handover) モブプロ用にブランチを作成し、そこにcommitしていくので、ブランチをクリーンに保つことができる なお、mobはGitHub上でOSSになっており、Go言語で実装されているようです。 インストール 基本的には以下のコマンドを使うことでインストールできます。 # works for macOS, linux, and even on windows in git bash curl -sL install.mob.sh | sh macOSの場合はHomebrewを使ってもインストールできます。 brew install remotemobprogramming/brew/mob 実際に使ってみる 必要なコマンドは mob start 、 mob next 、 mob done のみです! mob start まず、 mob start でモブプログラミングを開始します。 mob-programming-test というリポジトリの main ブランチでモブプロすることを想定します。 mob-programming-test (main)$ mob start git fetch origin --prune git merge FETCH_HEAD --ff-only > starting new session from origin/main git checkout -B mob/main origin/main git push --no-verify --set-upstream origin mob/main > you are on wip branch 'mob/main' (base branch 'main') > It's now 19:12. Happy collaborating! :) git branch コマンドを見てみます。すると、 main ブランチから mob/main というブランチが新たに作成され、 checkout していることがわかります。モブプロ中はこちらで作業していきます。   mob-programming-test (mob/main)$ git branch main* mob/main ちなみに、 mob start はタイマーを設定することができます。以下のコマンドでは 10 と設定しました。現在時刻が19:57なので、20:07までの10分間のタイマーが設定されました。 mob-programming-test (main)$ mob start 10 git fetch origin --prune git merge FETCH_HEAD --ff-only > starting new session from origin/main git checkout -B mob/main origin/main git push --no-verify --set-upstream origin mob/main > you are on wip branch 'mob/main' (base branch 'main') > It's now 19:57. 10 min timer ends at approx. 20:07. Happy collaborating! :) 10分経過すると、Macの場合はAppleScript ( osascript )を使って通知されるようになっています。さらに say コマンドを使って  mob next と読み上げてくれます。 mob next mob next は、タイピストが実施した全ての作業をコミットにまとめてリモートブランチに push し、次のタイピストへGitを経由してソースコードを渡します。 作業の例として sample.txt を生成します。 mob-programming-test (mob/main)$ echo 'Hello, World' > sample.txt その後 mob next でタイピストを交代します。 mob-programming-test (mob/main)$ mob next git add --all git commit --message mob next [ci-skip] [ci skip] [skip ci] lastFile:sample.txt --no-verify sample.txt | 1 + 1 file changed, 1 insertion(+) dd58204b0377d5fc732183df97d4c16a82c51c2b git push --no-verify origin mob/main 次の人はモブプロが始まったブランチ(この場合はmainブランチ)で mob start を実行します。 mob/main ブランチに checkout し、前のタイピストが実施したところまでのコミットをローカルに反映します。 mob-programming-test (main)$ mob start git fetch origin --prune git merge FETCH_HEAD --ff-only > joining existing session from origin/mob/main git checkout -B mob/main origin/mob/main git branch --set-upstream-to=origin/mob/main mob/main > you are on wip branch 'mob/main' (base branch 'main')dd58204 9 minutes ago <k0825> > It's now 19:34. Happy collaborating! :) mob done 何度か mob start 、 mob next を駆使し、モブプロで実装する機能が完成したとします。 mob done コマンドを実行し、派生元のブランチにマージしていきます! ここでは main ブランチへマージします。 mob-programming-test (mob/main)$ mob done git fetch origin --prune git add --all git commit --message mob next [ci-skip] [ci skip] [skip ci] lastFile:fizz.txt --no-verify fizz.txt | 1 + 1 file changed, 1 insertion(+) 2df8c40f6badd01c6813d7f44d63bc01758d8f0c git push --no-verify origin mob/main git checkout main git merge origin/main --ff-only git merge --squash --ff mob/main git merge --squash --ff mob/main git branch -D mob/main git push --no-verify origin --delete mob/main fizz.txt | 1 + sample.txt | 1 + 2 files changed, 2 insertions(+) To finish, use git commit あとは git commit し、リモートブランチへ push するだけです! mob-programming-test (main)$ git commit [main b292073] Squashed commit of the following: 2 files changed, 2 insertions(+) create mode 100644 fizz.txt create mode 100644 sample.txt mob-programming-test (main)$ git push origin HEAD Enumerating objects: 5, done. Counting objects: 100% (5/5), done. Delta compression using up to 8 threads Compressing objects: 100% (2/2), done. Writing objects: 100% (4/4), 497 bytes | 497.00 KiB/s, done. Total 4 (delta 0), reused 0 (delta 0), pack-reused 0 To <https://github.com/k0825/mob-programming-test.git> 9e30517..b292073 HEAD -> main まとめ mobコマンドはリモートでモブプロをするときに便利! それぞれのコマンドはGit Handoverで実現されているのでソースコードの受け渡しが簡単にできる! mob start : モブプロを開始する mob next : タイピストの交代 mob done : モブプロを終了する リモートでモブプロを実施するときに使ってみてください! We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering   明日は、 @spicy_laichi さんの記事です。お楽しみに!
アバター
この記事は、 ニフティグループ Advent Calendar 2022 13日目の記事です。 はじめに 最近ChatGPTにハマっている柴田です。普段はFlutterの開発を個人的に楽しんでいます。今回は、FlutterでHero Animationsを実装してみます。 Hero とは FlutterのHero Animationsとは、アプリらしいシームレスなアニメーションのことで、Androidで言うshared element transitions、iOSでいうSwiftUI2.0で追加されたmatchedGeometryEffectのようなものです。 遷移先の画面に遷移元と共通または似ている要素がある場合にHero Animationsを使用することでシームレスに画面遷移することができます。 Flutterでは、標準で用意されているMaterial LibraryのHero Widgetを使うことで簡単に実装することができます。 詳細はこちら https://docs.flutter.dev/development/ui/animations/hero-animations 実装する カードをタップすると詳細が表示されるUIを実装します。 Heroを使用しない実装 Heroを使う前のコードと動作例です。 Card( child: InkWell( splashColor: Colors.blue.withAlpha(30), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => DescriptionScreen(cardContent: cardContent), ), ); }, child: Column( children: [ Container( height: 150, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(5)), image: DecorationImage( fit: BoxFit.cover, image: Image.asset(cardContent.imageAssets).image, ), ), ), ListTile( title:Text( cardContent.title, style: Theme.of(context).textTheme.titleMedium, ), ), ], ), ), ); Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Image.asset(cardContent.imageAssets), Padding( padding: const EdgeInsets.all(30), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( cardContent.title, style: Theme.of(context).textTheme.displayMedium, ), const SizedBox(height: 20), Text( cardContent.description, style: Theme.of(context).textTheme.bodyLarge, ), ], ), ), ], ), Heroを使用した実装 このコードにHero Animationsを実装していきます。実装と言ってもImageやTextをHero Widgetでラップするだけです。 Hero Widgetが同一のTagが設定されているWidget間でいい感じにアニメーションを計算してくれます。 Hero Widgetを使って実装したコードと動作例です。 Card( child: InkWell( splashColor: Colors.blue.withAlpha(30), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => DescriptionScreen(cardContent: cardContent), ), ); }, child: Column( children: [ Hero( tag: cardContent.imageAssets, child: Container( height: 150, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(5)), image: DecorationImage( fit: BoxFit.cover, image: Image.asset(cardContent.imageAssets).image, )), ), ), ListTile( title: Hero( tag: cardContent.title, child: Text( cardContent.title, style: Theme.of(context).textTheme.titleMedium, ), ), ), ], ), ), ); Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Hero( tag: cardContent.imageAssets, child: Image.asset(cardContent.imageAssets), ), Padding( padding: const EdgeInsets.all(30), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Hero( tag: cardContent.title, child: Text( cardContent.title, style: Theme.of(context).textTheme.displayMedium, ), ), const SizedBox(height: 20), Text( cardContent.description, style: Theme.of(context).textTheme.bodyLarge, ), ], ), ), ], ), 注意 HeroでラップするTextにはstyleを付ける必要があります。 Styleを指定していないと、下の図のようにHero Animationsの処理が上手く行われず、アニメーション時に文字がはみ出してしまいます。 今回実装した全体のコードはこちらで公開しています https://github.com/ShibataRyusei/flutter-hero-demo 余談 今回の動作例はiOSですが、もちろんAndroid、Web、macOSでも動作しました。(Windows, Linuxは未検証) 一度の実装で複数のOSに書き出せるのは、何度体験しても感動します。 終わりに 今回は、FlutterでHero Animationsを実装してみました。ImageやTextなどのWidgetをHero Widgetでラップするだけでアプリらしいアニメーションが簡単に実装できました。このようなWidgetsが標準で用意されている点からFlutterのDX(Developer Experience)の高さを感じ、ストレスフリーに開発ができました。 みなさんもFlutterで快適な開発体験を!! 明日(14日目)は、 ike-chan さんです。お楽しみに! We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
この記事は、 ニフティグループ Advent Calendar 2022 11日目の記事です。 はじめに 会員システムグループ N1!Machine Learning Product Engineerの中村です。 ニフティでは11/22にNIFTY Tech Dayというイベントを開催しました。 この時に、ニフティニュースにおける深層自然言語処理によるニュース記事要約について発表をさせていただきましたが、この時に行った転移学習(ファインチューニング)について技術的な解説をしていきます。 モデルの転移学習について 本記事の大まかな実装は sonoisa さんの記事を参考に構築しています。 https://qiita.com/sonoisa/items/a9af64ff641f0bbfed44 言語モデルの転移学習 現在の言語モデルは非常に大規模であり、全く学習されていない状態(スクラッチ)から改めて学習させることは現実的ではありません。そこで考案されたのが転移学習という手法です。 転移学習とは、既に学習された大規模な言語モデルを元に、新しいタスクについての学習を行う手法です。この方法は、新しいタスクの学習データが少ない場合に特に有効です。また、既に学習されたモデルの学習済みの知識を引き継ぐことで、学習がスムーズに進むため、学習時間の短縮が期待されます。 プロンプト学習 https://arxiv.org/abs/1910.10683 今回のニュース記事要約ではT5というモデルの学習を行いますが、この学習にはプロンプトを用いて学習を行います。現在の深層学習モデルは非常に大規模であり、1つのモデルで複数のタスクを実行可能です。プロンプトと呼ばれる接頭辞を頭に付けて学習を行うことで、文章に対して何を行いたいかを指定し、それに合わせたタスクを実行するように学習します。 実装と学習の実行 ここではニフティニュースにおける記事データと要約データを用いて、T5モデルの転移学習を行います。 データセットの準備 ノーマライズ処理 まず、neologdの正規化処理を改変した処理を用いて、正規化処理を定義します # https://github.com/neologd/mecab-ipadic-neologd/wiki/Regexp.ja から引用・一部改変 from __future__ import unicode_literals import re import unicodedata def unicode_normalize(cls, s): pt = re.compile('([{}]+)'.format(cls)) def norm(c): return unicodedata.normalize('NFKC', c) if pt.match(c) else c s = ''.join(norm(x) for x in re.split(pt, s)) s = re.sub('-', '-', s) return s def remove_extra_spaces(s): s = re.sub('[  ]+', ' ', s) blocks = ''.join(('\u4E00-\u9FFF', # CJK UNIFIED IDEOGRAPHS '\u3040-\u309F', # HIRAGANA '\u30A0-\u30FF', # KATAKANA '\u3000-\u303F', # CJK SYMBOLS AND PUNCTUATION '\uFF00-\uFFEF' # HALFWIDTH AND FULLWIDTH FORMS )) basic_latin = '\u0000-\u007F' def remove_space_between(cls1, cls2, s): p = re.compile('([{}]) ([{}])'.format(cls1, cls2)) while p.search(s): s = p.sub(r'\1\2', s) return s s = remove_space_between(blocks, blocks, s) s = remove_space_between(blocks, basic_latin, s) s = remove_space_between(basic_latin, blocks, s) return s def normalize_neologd(s): s = s.strip() s = unicode_normalize('0-9A-Za-z。-゚', s) def maketrans(f, t): return {ord(x): ord(y) for x, y in zip(f, t)} s = re.sub('[˗֊‐‑‒–⁃⁻₋−]+', '-', s) # normalize hyphens s = re.sub('[﹣-ー—―─━ー]+', 'ー', s) # normalize choonpus s = re.sub('[~∼∾〜 ~]+', '〜', s) # normalize tildes (modified by Isao Sonobe) s = s.translate( maketrans('!"#$%&\'()*+,-./:;<=>?@[¥]^_`{|}~。、・「」', '!”#$%&’()*+,-./:;<=>?@[¥]^_`{|}〜。、・「」')) s = remove_extra_spaces(s) s = unicode_normalize('!”#$%&’()*+,-./:;<>?@[¥]^_`{|}〜', s) # keep =,・,「,」 s = re.sub('[’]', '\'', s) s = re.sub('[”]', '"', s) return s そのほかにタブ文字やタグの除去、エスケープシーケンスの復号化などを行い、データセットを整備します。 参考元の処理ではlower処理やスペースの削除などが含まれていますが、例えば「iPhone」などの商品名や、空白によって意図的に区切られている部分が消滅するなどの現象が起こったため、本記事ではその部分の処理を行わないように実装しています。 ニフティニュースでは短文と長文の2種類のタイトルの他、3行要約も作成しており、そのデータにそれぞれプロンプトを付与し学習させます。 import re import numpy as np import pickle from tqdm import tqdm tag_regex = re.compile(r"<[^>]*?>") def normalize_text(text): text = text.replace("\t", " ") text = normalize_neologd(text) text = tag_regex.sub("", text) text = text.replace(""", "\"").replace("&", "&").replace("<", "<").replace(">", ">").replace(" ", " ") return text all_data = [] count = 0 for index, data in news_data.iterrows(): if data['body'] is None or data['body'] is np.nan or not data['body']: continue normalized_body = normalize_text(data['body']) all_data.append({"text": "keyword: " + normalized_body,"response": normalize_text(data['keyword_str_1']),}) all_data.append({"text": "keyword: " + normalized_body,"response": normalize_text(data['keyword_str_2']),}) all_data.append({"text": "keyword: " + normalized_body,"response": normalize_text(data['keyword_str_3']),}) all_data.append({"text": "topics_title: " + normalized_body,"response": normalize_text(data['topics_article_title']),}) all_data.append({"text": "title: " + normalized_body,"response": normalize_text(data['title']),}) if data['long_title'] is not None and data['long_title'] is not np.nan: all_data.append({"text": "long_title: " + normalized_body,"response": normalize_text(data['long_title']),}) if data['summary_1'] is not None and data['summary_1'] is not np.nan: all_data.append({"text": "summary_1: " + normalized_body,"response": normalize_text(data['summary_1']),}) if data['summary_2'] is not None and data['summary_2'] is not np.nan: all_data.append({"text": "summary_2: " + normalized_body,"response": normalize_text(data['summary_2']),}) if data['summary_3'] is not None and data['summary_3'] is not np.nan: all_data.append({"text": "summary_3: " + normalized_body,"response": normalize_text(data['summary_3']),}) プロンプトも含めた本文であるtextと、それに対応する応答であるresponseを、all_dataという配列に含めた状態になりました。 データセットの分割 データをtrain/validation/testの3つに分割します。 import random from tqdm import tqdm random.seed(1234) random.shuffle(all_data) def to_line(data): text = data["text"] response = data["response"] assert len(text) > 0 and len(response) > 0 return f"{text}\t{response}\n" data_size = len(all_data) train_ratio, val_ratio, test_ratio = 0.95, 0.03, 0.02 with open(f"data/train.tsv", "w", encoding="utf-8") as f_train, \ open(f"data/val.tsv", "w", encoding="utf-8") as f_val, \ open(f"data/test.tsv", "w", encoding="utf-8") as f_test: for i, data in tqdm(enumerate(all_data)): line = to_line(data) if i < train_ratio * data_size: f_train.write(line) elif i < (train_ratio + val_ratio) * data_size: f_val.write(line) else: f_test.write(line) 確認してみると、ランダムにデータが分割されたことがわかります。 学習の実行 モデルの定義などは参考元と同じため、ここでは割愛します。 https://qiita.com/sonoisa/items/a9af64ff641f0bbfed44 事前学習モデルには megagonlabs/t5-base-japanese-web を使用します。 A100(80GB)時の設定について Google ColaboratoryなどでGPUにA100を使う場合、プリインストールされているtorchではCUDAが対応していないというエラーが起きます。その場合には、以下のコマンドで対応するtorchなどをインストールします。 !pip install -qU transformers[ja] pytorch_lightning sentencepiece torch==1.10.0+cu111 torchvision==0.11.1+cu111 torchaudio torchtext -f https://download.pytorch.org/whl/torch_stable.html その他ハイパーパラメータ ハイパーパラメータについては以下のように設定します。 モデルのチェックポイント周りの定義を行っておくと、もしも途中で学習が終了する(PCが止まる、Google Colaboratoryのセッションが切れてしまう)自体に陥っても、学習を途中から始めることが可能なため設定しておくことをおすすめします。 (どうしても時間がかかってしまうような、このような大規模モデルの学習では非常に便利だと感じました) # 学習に用いるハイパーパラメータを設定する args_dict.update({ "max_input_length": 1024, # 入力文の最大トークン数 "max_target_length": 64, # 出力文の最大トークン数 "train_batch_size": 8, "eval_batch_size": 8, "num_train_epochs": 2, }) args = argparse.Namespace(**args_dict) train_params = dict( accumulate_grad_batches=args.gradient_accumulation_steps, gpus=args.n_gpu, max_epochs=args.num_train_epochs, precision= 16 if args.fp_16 else 32, amp_backend='apex', amp_level=args.opt_level, gradient_clip_val=args.max_grad_norm, default_root_dir=f"{MODEL_SAVE_DIR}/checkpoint", ) 以下を実行して、転移学習を行います。 # 転移学習の実行 model = T5FineTuner(args) trainer = pl.Trainer(**train_params) trainer.fit(model) # 最終エポックのモデルを保存 model.tokenizer.save_pretrained(MODEL_DIR) model.model.save_pretrained(MODEL_DIR) 推論処理 以下のコードを実行することで推論処理を行います。 article_body = "本文" MAX_SOURCE_LENGTH = 1024 # 入力される記事本文の最大トークン数 MAX_TARGET_LENGTH = 64 # 生成される出力の最大トークン数 import re import pickle from tqdm import tqdm tag_regex = re.compile(r"<[^>]*?>") def normalize_text(text): text = text.replace("\t", " ") text = normalize_neologd(text) text = tag_regex.sub("", text) return text def preprocess_body(text): return normalize_text(text.replace("\n", " ")) # 推論モード設定 trained_model.eval() # 前処理とトークナイズを行う preprocessed_body = preprocess_body(article_body) inputs = ["title: " + preprocessed_body, "long_title: " + preprocessed_body, "topics_title: " + preprocessed_body, "summary_1: " + preprocessed_body, "summary_2: " + preprocessed_body, "summary_3: " + preprocessed_body] batch = tokenizer.batch_encode_plus( inputs, max_length=MAX_SOURCE_LENGTH, truncation=True, padding="longest", return_tensors="pt") input_ids = batch['input_ids'] input_mask = batch['attention_mask'] if USE_GPU: input_ids = input_ids.cuda() input_mask = input_mask.cuda() # 生成処理を行う outputs = trained_model.generate( input_ids=input_ids, attention_mask=input_mask, max_length=MAX_TARGET_LENGTH, temperature=1.0, # 生成にランダム性を入れる温度パラメータ num_beams=10, # ビームサーチの探索幅 diversity_penalty=1.0, # 生成結果の多様性を生み出すためのペナルティ num_beam_groups=10, # ビームサーチのグループ数 num_return_sequences=1, # 生成する文の数 repetition_penalty=1.5, # 同じ文の繰り返し(モード崩壊)へのペナルティ ) # 生成されたトークン列を文字列に変換する generated = [tokenizer.decode(ids, skip_special_tokens=True, clean_up_tokenization_spaces=False) for ids in outputs] # 生成された文字列を表示する for i, generated_str in enumerate(generated): if i == 0: print(f"title: {generated_str}") elif i == 1: print(f"long_title: {generated_str}") elif i == 2: print(f"topics_title: {generated_str}") else: print(f"summary {i-2}: {generated_str}") # 前処理とトークナイズを行う の部分でプロンプトを与えつつ、トークナイズを行います。 # 生成処理を行う の生成処理ではハイパーパラメータを変更することで、処理時間や精度のトレードオフ関係を調整します。 実際の推論 実際の推論は以下のようになります。(NIFTY Tech Day 2022より) 全てにおいて成功するわけではなく、以下のように失敗する例もあります。 おわりに 今回はNIFTY Tech Day 2022では話せなかった細かい転移学習の手法などについて書いてきました。 深層学習周りの自然言語の歴史や、クラウド上のアーキテクチャに関してもNIFTY Tech Day 2022でお話したので、 興味がある方はぜひ御覧ください。 最近だとChatGPTのような流暢な対話型のAI技術も登場し、いよいよ自然言語処理は人間に近い存在になりつつありますね。 実際に深層自然言語処理による要約生成などに挑戦してどのような苦労があったのか?についてもまたどこかの機会に発表したいと思います。 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering この記事は、 ニフティグループ Advent Calendar 2022 11日目の記事です。 明日は ニフティライフスタイル のsaikeiさんです。次回もお楽しみに!
アバター
この記事は、 ニフティグループ Advent Calendar 2022  10日目の記事です。 はじめに こんにちは!インフラシステムグループの仲上と申します。 今回は、Slackでインタラクティブなリマインダーアプリを作成したので、紹介したいと思います。 背景 突然ですが、皆さんはSlackを使っていますか?Slackには優秀なリマインダー機能があり、繰り返し設定や日付時間指定、リマインド相手など細かいところまで設定することができます。 しかし、こちらの機能はコマンドの構文を覚えるのが大変です。特に繰り返し系のコマンドは少し複雑なので、覚えるまで苦労します。(英語ができれば自然と書けると思いますが、筆者は英語が全くできません!) また、忙しいタスクの中で、コマンドを打ってリマインダーをセットするのは大変ですよね。 そこで、Slackのショートカットから呼び出せるインタラクティブなアプリを作ってみました。 インタラクティブとは 「 対話 」または「 双方向 」といった 意味 で、ユーザーがパソコンの画面を見ながら、対話をするような 形式 で 操作 する 形態 を指す。 https://kotobank.jp/word/%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%A9%E3%82%AF%E3%83%86%E3%82%A3%E3%83%96-901 ここでは、modal(Slackのショートカットをクリックしたときに表示されるポップアップのこと)への入力内容に反応して自動で入力項目が書き換わっていることから「インタラクティブ」と呼ばせていただきました。 成果物 アプリには以下の機能があります 単発のリマインドセット 繰り返しのリマインドセット 毎日、毎週、毎月、毎年から選択 投稿先指定 構成 Slackは公式ライブラリとして、 Slack bolt を公開しています。こちらのライブラリがとても便利なので、今回はNode.js + Slack bolt の構成でAWSのlambdaにデプロイしました。 デプロイには、Serverless Frameworkを用いています。 modal(ショートカットをクリックしたときに表示される画面)は Slack Block Kit を用いて作成しました。 費用については、リクエストに応じて課金されるので、個人で使用する分にはほとんどかかりません。(1月当たり数百円程度) 作り方 コードはすべて こちらのリポジトリ に公開しています。 準備物 Node.js(自分のローカルは v16.17.0) AWSアカウント AWS CLI のインストールとプロファイル設定 手順 1.Slackアプリの設定 2.環境構築系 3.Serverless Frameworkの整備 4.Lambda関数の準備 5.環境変数の設定 6.デプロイ 7.イベント通知先のURLを設定 より詳細な手順に関しては、以前別の社員が こちらの記事 で紹介しているので、今回はコードとコマンドのみ紹介させていただきます。 1.Slackアプリの設定 display_information: name: reminder_maker features: bot_user: display_name: reminder_maker always_online: false shortcuts: - name: リマインダーセット type: global callback_id: socket-mode-shortcut description: リマインドコマンド生成 oauth_config: scopes: bot: - channels:history - chat:write - reminders:write - reminders:read - commands settings: event_subscriptions: request_url: https://example.com # イベント送信先。後で変えるので一旦適当 bot_events: - message.channels interactivity: is_enabled: true request_url: https://example.com # イベント送信先。後で変えるので一旦適当 org_deploy_enabled: false socket_mode_enabled: false token_rotation_enabled: false 2.環境構築系 Serverless Framework の設定 $ npm init (npm initの設定はすべて何も入力せずEnterでOK) $ npm i -g serverless # Serverless Frameworkインストール ライブラリの準備 $ npm install @slack/bolt 3.Serverless Frameworkの整備 service: serverless-bolt-js frameworkVersion: '3' provider: name: aws runtime: nodejs14.x region: ap-northeast-1 environment: SLACK_SIGNING_SECRET: ${env:SLACK_SIGNING_SECRET} SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} functions: slack: handler: app.handler events: - http: path: slack/events method: post plugins: - serverless-offline 4.Lambda関数の準備 今回コードが長くなってしまったので、ピックアップして紹介します。 全文はGitHubのリポジトリを見てください。 この辺りはNode.js + Lambda でインタラクティブアプリを作るための初期化です。 // Initialize your custom receiver const awsLambdaReceiver = new AwsLambdaReceiver({ signingSecret: process.env.SLACK_SIGNING_SECRET, }); // Initializes your app with your bot token and the AWS Lambda ready receiver const app = new App({ token: process.env.SLACK_BOT_TOKEN, usr_token: process.env.SLACK_USER_TOKEN, receiver: awsLambdaReceiver, }); ショートカットが押された時の動作を定義しています。 app.shortcut('socket-mode-shortcut', async ({ shortcut, ack, context, logger }) => { ~ } 送信先の選択肢が変更された時のmodal更新を行っています。 Slack boltでは、app.actionにactionIDを渡してあげることでボタンが押された時の動作を定義することができます。 app.action('actionId-3', async ({ ack, context, logger, body, payload }) => { ~ } 送信ボタンが押された時の制御をしています。 app.view('modal-id', async ({ ack, body, view, client, logger, payload, context }) => { ~ } 最後にLambda関数のイベント処理を書いて完成です。 module.exports.handler = async (event, context, callback) => { const handler = await awsLambdaReceiver.start(); return handler(event, context, callback); }; 5.環境変数の設定 $ export SLACK_SIGNING_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx $ export SLACK_BOT_TOKEN=xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 6.デプロイ $ sls deploy 7.イベント通知先のURLを設定 デプロイ後に表示されたURLをここに書きます。 動作確認 すべての設定が完了すると、ショートカットが選択できるようになっているはずです。 ここをクリックすると、最初に紹介したmodalが表示され、リマインドが登録できると思います。 まとめ 今回は、Slackのリマインド設定を楽にするために、インタラクティブなアプリを作成してみました。 Slack boltに初めて触ってみましたが、イベントの制御が非常に優秀でとても使いやすかったです。また、今回のように動きを持ったアプリのほうが、使っていて楽しいなと感じました。 普段コードをあまり書かないため、今回書いたコードはとても長くなってしまいました。modalのブロック部分はほかのjsonファイルに保存して、適宜呼び出すような形にしたほうが良いかもしれません。 また、現状はリマインドの登録機能のみ実装されているので、削除機能やリスト表示機能なども追加したいと考えています。 皆さんもぜひ使ってみてください! 次回予告 明日は「 @ibukinakamura 」さんの「Google Colaboratoryで自然言語処理モデルT5をチューニングする」です!お楽しみに! 参考 https://slack.dev/bolt-js/ja-jp/tutorial/getting-started https://slack.dev/bolt-js/ja-jp/deployments/aws-lambda https://api.slack.com/block-kit 【初心者でもできる!】SlackのTimesチャンネルの開設を通知しよう! We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
NIFTY Tech Talkは、ニフティ株式会社の社員が主催するトークイベントです。 本イベントでは、ニフティグループの社員が業務を通じて学んだことを発信しています! 第8回目のテーマは「ニフティのデータ分析を語る会」です。 ニフティのデータ分析部隊のメンバーから実際の業務で培われたノウハウについて、LTと質疑応答を交えたディスカッション形式で語っていただきます。 概要 日程:12月20日(火)12:00〜13:00 配信方法:YouTube Live 視聴環境:インターネット接続が可能なPC/スマートフォン 参加方法 YouTube Liveにて配信いたします。 connpass にて登録をお願いいたします。 YouTube LiveのURLは決定後、connpass内の参加者への情報欄に記載いたします。 こんな方におすすめ データ分析系の業務に関わるエンジニア ニフティの現場で使われているデータ基盤やデータ分析の技術について興味がある方 ニフティの技術や風土に興味がある方 タイムテーブル 時間 コンテンツ 12:00 – 12:05 オープニング+会社紹介 12:05 – 12:10 登壇者紹介 12:10 – 12:25 データ基盤について 12:25 – 12:40 Tableau、TableauServerの社内の活用事例 12:40 – 12:55 ChatGPTで賑わう自然言語処理技術、ニフティのデータサイエンスについて 12:55 – 13:00 まとめ+クロージング テーマ ニフティでデータ分析・データ基盤の業務に携わっている方々に登壇していただき、LT形式で発表していただきます。 データ基盤について Tableau、TableauServerの社内の活用事例 ChatGPTで賑わう自然言語処理技術、ニフティのデータサイエンスについて 登壇者プロフィール 松下 俊平(ファシリテータ) 管理会計・データ分析チームで購買データやお客様の声のデータ分析をしています。 分析でよく使うのはSQL、Tableauです。 黒羽 孝夫(登壇者) 継続的にデータ活用・分析を実現するために、データ基盤を続けているニフティN1!データアーキテクト。 いかに楽しく働くかってことを考えてます。 打矢 光(登壇者) 『データエンジニアの方に整えて貰ったデータ基盤』と『ビジネス的なドメイン知識』・『分析技術』を掛け合わせて、 データ分析起点での、①社内各所からの分析相談 ②問題提起や解決提案 などを行っています。 瀬川 雄太(登壇者) ニフティN1!データサイエンティストの瀬川です。 データセンター管理やクラウド開発を学んだと思ったら、気づけばコンテナ開発や機械学習をしていたエンジニアの端くれです。 今は経営管理部門に異動してビジネスを学び、データを活かして主力商品のマーケティング推進に携わっています。 ニフティグループでは一緒に働く仲間を募集中です 新卒採用、キャリア採用を実施しています。ぜひ リクルートサイト をご覧ください。 ニフティエンジニアが業務で学んだことやイベント情報を エンジニアブログ にて発信しています! ニフティエンジニアのTwitterアカウントを作りました NIFTY Tech Talkのことや、ニフティのエンジニアの活動を発信していきます。 Tweets by NIFTYDevelopers 「NIFTY Tech Day 2022」を開催しました 技術イベント「NIFTY Tech Day 2022」のアーカイブはこちら NIFTY Tech Day 2022 アンチハラスメントポリシー 私たちは下記のような事柄に関わらずすべての参加者にとって安全で歓迎されるような場を作ることに努めます。 社会的あるいは法的な性、性自認、性表現(外見の性)、性指向 年齢、障がい、容姿、体格 人種、民族、宗教(無宗教を含む) 技術の選択 そして下記のようなハラスメント行為をいかなる形であっても決して許容しません。 不適切な画像、動画、録音の再生(性的な画像など) 発表や他のイベントに対する妨害行為 これらに限らない性的嫌がらせ 登壇者、主催スタッフもこのポリシーの対象となります。 ハラスメント行為をやめるように指示された場合、直ちに従うことが求められます。ルールを守らない参加者は、主催者の判断により、退場処分や今後のイベントに聴講者、登壇者、スタッフとして関わることを禁止します。 もしハラスメントを受けていると感じたり、他の誰かがハラスメントされていることに気がついた場合、または他に何かお困りのことがあれば、すぐにご連絡ください。 ※本文章はKotlinFest Code of Conductとして公開された文章( https://github.com/KotlinFest/KotlinFest2018/blob/master/CODE-OF-CONDUCT.md )を元に派生しています。 ※本文章はCreative Commons Zero ライセンス( https://creativecommons.org/publicdomain/zero/1.0/ ) で公開されています。
アバター
この記事は、 ニフティグループ Advent Calendar 2022  8日目の記事です。 はじめに こんにちは!会員システムグループの渡邊です。普段はニフティトップページの開発運用を担当しています。 今回はニフティトップページでも採用しているNext.jsを使ったサイトの表示速度を改善する方法について紹介します。 実行環境 MacBook Pro(M1、2021) macOS v12.6.1 Next.js v13.0.6 next/image 画像ファイルはウェブページ全体のバイト数の半分を占めると言われるほど、ページを読み込むときに負荷がかかる部分です。 こういった問題を解決するために画像サイズを縮小させるTinyPNGなどのサイトを使ったり、軽量な画像フォーマットに変換するという作業を行うことがあると思います。 Next.jsでは、next/imageと呼ばれるコンポーネントを呼び出すことで、軽量な画像フォーマットに自動で変換する仕組みを備えています。 使い方は単純で以下のようにコンポーネントを呼び出し、必要なプロパティを渡してあげるだけです。 以下の例では、1920×1080の画像を読み込み500×250リサイズしています。 import Image from 'next/image' import styles from '../styles/Home.module.css' export default function Home() { return ( <div className={styles.container}> <h1>画像テスト</h1> <div>next/image</div> <Image src="/tree.jpg" alt="tree" width={500} height={250} /> <div>imgタグ</div> <img src="/tree.jpg" alt="tree" width={500} height={250} /> </div> ) } 仕組みとしてはImageコンポーネントで読み込んだ画像をGoogleが開発している次世代画像フォーマットのwebpに変換します。 テスト用にnext/imageとimgタグを使って2枚の画像を表示してみると以下のようになります。特に劣化は感じることはなく、サイズを見ると1/10以下に削減できています。 https://nextjs.org/docs/api-reference/next/image next/dynamic 次にES2020から追加された新機能の動的インポートをNext.jsでも行えるようにする機能です。 トグルやモーダルなどの初回ロード時では不要な処理をユーザーが操作したときに都度読み込みを行うようにするものです。 一部の処理を動的インポートにすることで初回ロード時に読み込む量を減らすことができます。 静的インポートと動的インポートの書き方の違いは以下のようになります。 https://nextjs.org/docs/advanced-features/dynamic-import // static import import List from './list' import List2 from './list2' // dynamic import import dynamic from 'next/dynamic'; const List = dynamic(() => import('./list')) const List2 = dynamic(() => import('./list2')) next/script 外部スクリプト読み込みは、パフォーマンスに大きな影響を与える原因の一つです。 外部スクリプトはこちらから手を入れることができず、そのまま読み込むしかないため、パフォーマンス改善の中では一番苦しめられる部分だと個人的には思います。 こういった問題を改善するために登場したのがnext/scriptです。 next/scriptは、外部スクリプトの実行順序を制御することができ、strategyというプロパティを使用することで自動的に優先順位をつけることができます。 書き方は以下のようにScriptコンポーネントにsrcとstrategyプロパティを渡してあげるだけです。 <Script src="<https://example.com/script.js>" strategy="beforeInteractive" /> strategyには3つのプロパティが用意されています。 beforeInteractive ページが表示される前に読み込みが開始する バンドルされたJavaScriptを読み込むより前に実行することができる 最優先で読み込まれるので、実行速度が求められる広告スクリプトなどで有用 afterInteractive プロパティを何も指定していないと選ばれるもの ページが表示された直後に読み込みが開始される タグマネージャーやGAなどのスクリプトに有用 lazyOnload ページが表示されてアイドル状態になった後に読み込みが開始される すぐに読み込む必要がなく、ページが完全に表示されてから使われるようなチャットボットやソーシャルメディアウィジェットなどで有用 以上のことから beforeInteractive > afterInteractive > lazyOnloadの順番でスクリプトが読み込まれるということになります。 https://nextjs.org/docs/api-reference/next/script バンドルサイズを確認する 最後にバンドルされたファイルに含まれる各パッケージの容量を可視化するツールを使って、アプリケーションのどこの読み込みに時間がかかっているか可視化してみます。 webpack-bundle-analyzerをインストールします。 yarn add -D @next/bundle-analyzer next.config.jsに以下の設定を追加します。 const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); module.exports = withBundleAnalyzer({}); 最後にコマンドを実行します。 ANALYZE=true yarn build 今回はライブラリによる読み込みサイズを見るために日付ライブラリを複数導入し、同じ処理を実行してみました。 日付ライブラリでメジャーなday.js、date-fns、luxonを入れて検証しました。 3つ比較してみましたが、luxonが圧倒的に重い処理をしていることがひと目で分かります。同じような処理をしているライブラリでもここまでの差が生まれるので、慎重に選ばないといけないことがわかります。 加えてコンポーネント単位での確認もできるので、どの機能に原因があるかの特定が容易にできるのも良い点です。 まとめ 以上がニフティトップで採用しているパフォーマンス改善の一部になります。どれもNext.jsを採用しているサイトならすぐに実施できることなので、積極的に活用することをおすすめします。 パフォーマンス改善において大事なのは可視化できることなので、原因を自動で特定し、数値化するLightHouse ciの導入もおすすめです。 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering 明日は、 nakanowai さんの「AWS re:Invent 2022で発表されたこと」のまとめ記事です。 お楽しみに!
アバター
この記事は、 ニフティグループ Advent Calendar 2022 5日目の記事です。 はじめに 会員システムグループ SREチームの浅見( @rubihiko )です。 インシデント管理、リスク分析の重要性の高まりから色々調べていたところ、以下の記事の記事をみて良いと思ったので試してみました。 その SLO は現実的ですか?SRE 的なリスク分析手法 Googleでは 稼働準備レビュー(PRR) 時に リスクを優先順位付けして明確化 することを推奨しているようで、ニフティでも Production Readiness Checklist を作って運用し始めているので、取り入れていきたいなと思っています。 用語の説明 平均検出時間(MTTD:Mean Time To Detect) 問題が発生し、修復する人(やシステム)が検知するまでの平均時間です 平均修復時間(MTTR:Mean Time To Repair) 人(やシステム)が問題を検知して、それを修復するためにかかった平均時間です 平均障害間隔(MTBF:Mean Time Between Failures) 障害を復旧させサービスが使えるようになってから、次の障害が発生するまでの平均時間です MTTD と MTTR と MTBFの関係 その SLO は現実的ですか?SRE 的なリスク分析手法 年間の損失 以下の数式でリスクに対しての年間の損失が導けます (MTTD + MTTR) * (365.25 / MTBF) * percent of affected users MTTD(分): 5分 MTTR(分): 120分 MTBF(日): 365日 (5 + 120) * (365.25 / 365) * 100% = 125分 年間で約125分停止するリスクがあるとわかります リスク分析 MTTD, MTTR, MTBFの関係がわかりました。 これにユーザーインパクトを加えると損失を導くことができました。 この計算式を使い、リスク分析へと進んでいきます。 今回は、サンプル事例とデータを使って見ていきたいと思います。 記事の中に、リスク分析のためのテンプレートがあるためこちらをコピーして使います。 このテンプレートはいくつかのシートで構成されており、リスクの洗い出し、要素の洗い出し、リスク許容の判断をすることが出来ます。 CRE Risk Analysis Template – Public ※ ここでは、テンプレートに書いてある用語を以下のよう置き換えて考えています ETTD = MTTD ETTR = MTTR ETTF = MTBF ETTD Estimated Time To Detection – how long it would take to detect and notify a human (or robot) that the risk has occurred; aka MTTD. ETTR Estimated Time To Resolution – how long it would take to fix the incident once the human (or robot) has been notified; aka MTTR. ETTF Estimated Time To Failure – estimated frequency between instances of this risk; aka MTBF. リスクカタログ リスクの洗い出しを行います。 過去にあった障害をベースに考えるのがよいと思います。 リスクカタログ このリスクカタログにある、 「新規機能リリースの影響で一部機能が利用不可」 の項目の場合の詳細としては、「リリースをしたが、ある一定数のユーザーに対してエラーとなっており、それに気がついたのは1日後だった。気がついた後10分で切り戻しを行い解消した。この障害は60日毎に発生してた。」としたものです。 つまり表に入力する値としては以下のようになります。 一定数のユーザーに対してエラー: impact 2% 気がついたのは1日後: MTTD 1440(min) 気がついた後10分で切り戻しを行い解消: MTTR 10(min) この障害は60日毎に発生してた: MTBF 60(days) 損失の計算のとおり、年間における障害の時間が算出されます。 年間の平均障害数: incidents/year 6.09(回) 年間の平均障害時間: bad mins/year 177(min) リスクファクター リスク全体に影響を及ぼすような要素を洗い出します リスクカタログで挙げたリストに対して、影響を与える要素を追加することになります。 追加する観点は、MTTD, MTTR, MTBFどれを使っても構いません。 リスクファクター 例えば、 「運用過負荷のため、障害検知時間(MTTD)がインシデント毎に+30分増加」 などは、通常の業務が忙しすぎたりしてアラートに気が付かない、他に優先度が高いタスクがありアラートを受け取れない、などが課題としてあり、全てのリスクに対して、MTTRが+30分増加するというものです。 また、 「運用手順書が不足しているため、障害復旧時間(MTTR)がインシデント毎に+10分増加」 はアラートに気が付き対処を開始したが、復旧のための運用手順書が不足しているので、通常よりMTTRが+10分増加してしまうというものです。 このように、全ての障害に影響を与える要素について、リスク(bad mins/year)として追加しているわけです。 リスクスタックランク これまで洗い出したリスクのカタログ・要素を並び替えます このテンプレートを使えば以下のよう自動で並び替えをしてくれます。 ここからは、SLOに対して、どの程度リスクを許容できるか確認するフェーズになります。 リスクスタックランク SLO 99.5% なら全てのリスクを許容してもエラー予算は余りますが・・・ SLO 99.5%の可用性は、年間43.8時間の停止を許容する目標となりますが、ユーザーに見せているサービスとしては低いので良くないように思えます。 社内で利用しているツールや実験的なサービスならありかもしれません。 リスクスタックランク: SLO 99.5% 99.9%, 99.99% を求める場合は当然許容できるリスクが減ります SLO 99.9%, 99.99%の場合はどうなるか見てみます。 SLOが厳しくなるほど許容できるリスクが少なくなっているのが下の図から分かると思います。 リスクを許容しすぎるとエラー予算がなくなってしまい、SLOを維持できなくなってしまいます。 これは、ユーザーへの信頼性低下へと繋がります。 ここでは、下から適当にリスク許容としていますが、実際にはSLOに基づいた適切なリスク許容をする必要があります。 このようにリスクを数値化したことで、データドリブンのアプローチが可能になったのはすごいことだと思います。 リスクスタックランク: SLO 99.99% リスクの許容 分析したリスクは、プロダクト開発にも影響する大きい要素です。 ビジネスサイド(PO)とエンジニアサイドで議論して決める必要があります。 リスクを許容した上で施策を進めるのか、それとも時間をとって改善するのか。 このようなデータがあることでプロジェクトの進行判断の材料になります。 まとめ リスクを数値化したことで潜在的なリスクを明るみにして、優先度付けできました。 テンプレが整っているので、Production Readiness Checklistにも容易に組み込めるのでとても良い考えだと思います。 リスクを可視化するには MTTD, MTTR, MTBF, ユーザーインパクトを使う MTTD: アラートが上がってから検知するまでの平均時間 MTTR: アラートを受け取ってから対応を完了するまでの平均時間 MTBF: サービスが正常になった点から次に障害発生するまでの平均時間 MTTD, MTTR をできるだけ短くすることが信頼性向上につながる MTBF をできるだけ長い期間にすることは信頼性向上につながる リスクを分析すること リスクやリスクを増加させる要素を洗い出し、数値化し、分析する リスクを評価し許容を検討する ユーザーインパクト、ビジネスインパクト、現実的な問題など話し合って決める SLOに応じてリスクは許容できることを理解する SLO 99.99%: ほぼ許容出来ない SLO 99.90%: 一部許容できる SLO 99.50%: ほとんど許容できる 明日は、 supreme3854 さんの tkinterで残業代計算 です。 お楽しみに! We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering
アバター
この記事は、 ニフティグループ Advent Calendar 2022 4日目の記事です。 はじめに こんにちは。ニフティ 会員システムグループ シニアエンジニアの伊達です。 AWS上で稼働するアプリケーションの開発をするにあたってIaC(Infrastructure as Code)を実践することは一般的になっています。ただ、そのツールにはいくつか候補があるでしょう。ニフティではTerraformを使うことが多くCDKは今のところ少数派です。 今回はCDKを使うにあたってのちょっとしたTipsを共有します(特に設定値に関するものをいくつか用意しました)。とはいえ、まだまだCDK初級者ですので、 @NIFTYDevelopers へ読者諸賢のTipsも教えていただけると嬉しいです。 なお、伊達はTerraformを通らずにCloudFormationとCDKを使い始めたため、それTerraformでも普通にできるよというものがあると思いますが目を瞑っていただけますと幸いです。 また、本記事はCDK v2を前提とし、CDKのコードの言語はTypeScriptを使っています。 CDKとは AWS CDKの特徴は既存のプログラミング言語を使ってAWS上でシステムを構築できる点です。 2022年12月現在ではTypeScript、Java、Python、C#、Go言語で記述ができます。開発者はアプリケーションのコードを書くのと同じようにIDEの恩恵を受けながらAWSのリソースのプロビジョニングをすることができます。 この記事ではCDKそのものの解説などはしません。詳しくはAWSの公式ページやGitHubを参照ください。 オープンソースの開発フレームワーク – AWS クラウド開発キット – Amazon Web Services https://github.com/aws/aws-cdk Tipsその1 Contextで設定値を与える CDKにはContextという仕組みがあり、CDKのStackなどにkey-value形式のデータを渡すことができます。cdk.jsonのcontext内がデフォルト値となります。 例えば cdk initしたばかりのcdk.jsonは以下のようになっています。 { "app": "npx ts-node --prefer-ts-exts bin/tmp.ts", "watch": { "include": [ "**" ], "exclude": [ "README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "tsconfig.json", "package*.json", "yarn.lock", "node_modules", "test" ] }, "context": { "@aws-cdk/aws-lambda:recognizeLayerVersion": true, "@aws-cdk/core:checkSecretUsage": true, "@aws-cdk/core:target-partitions": [ "aws", "aws-cn" ], "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, "@aws-cdk/aws-iam:minimizePolicies": true, "@aws-cdk/core:validateSnapshotRemovalPolicy": true, "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, "@aws-cdk/core:enablePartitionLiterals": true, "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, "@aws-cdk/aws-iam:standardizedServicePrincipals": true, "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true } } ここに値を追加することでStack内などで参照することができます。例えば、既存のVPCがあり、それを参照したいという場合には以下のように cdk.jsonに記述します。 ... "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, "vpc_id": "vpc-xxxxxxxxxxxxxxxx" } コード内では以下のようにして参照します。 import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; export class ExampleAppStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // vpcIdの値は"vpc-xxxxxxxxxxxxxxxx" const vpcId = scope.node.tryGetContext('vpc_id'); ... } } Tipsその2 環境を分ける まず、devlepment、staging、productionなど稼働環境を複数持つ場合には、AWSアカウント自体をわけることをおすすめします。同じアカウント内に複数の環境を作るとリソースの重複などを避ける手間があることと、誤った環境にデプロイするなどのオペミスが起きやすくなります。 その上で環境ごとに設定を分けるには、cdk.json内に”stage”といったキーでデータを追加します。 ... "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, "stage": "development" } これは以下のように参照できます。 import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; export class ExampleAppStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // stageの値は"development" const stage = scope.node.tryGetContext('stage'); ... } } 例えばグローバルでユニークな必要があるドメイン名やS3のバケット名を以下のようにしたいとします。development環境にはprefixをつけるパターンです。 環境 ドメイン名 S3バケット名 development dev- app.example.com dev- nifty-engineering-example-bucket production app.example.com nifty-engineering-example-bucket cdk.json内ではstage、domain、bucket_nameを設定します(stageがdevelopmentでほかがproduction用の値なのが不格好ではありますが……)。 ... "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, "stage": "development", "domain": "app.example.com", "bucket_name": "nifty-engineering-example-bucket" } CDKのコードではstageを参照してドメイン名やバケット名を組み立てます。 import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as route53 from 'aws-cdk-lib/aws-route53'; import * as s3 from 'aws-cdk-lib/aws-s3'; export class ExampleAppStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const stage = scope.node.tryGetContext('stage'); let domainPrefix = ''; if (stage == 'development') { domainPrefix = 'dev-'; } const domain = [domainPrefix, scope.node.tryGetContext('domain')].join(''); // 既存のRoute53ホストゾーンを参照 const hostedZone = route53.HostedZone.fromLookup(this, 'ExampleAppHostedZone', { domainName: domain, }); const bucketName = [domainPrefix, scope.node.tryGetContext('bucket_name')].join(''); const bucket = new s3.Bucket(this, 'ExampleAppBucket', { bucketName: bucketName, }); } } Contextはcdkコマンド実行時に上書き指定ができます。以下のようにすることで、コード内で参照される値を変えることができるため、環境ごとに異なる設定でデプロイができます。 $ cdk synth # 指定がないときにはcdk.jsonの値なので stage: "development" $ cdk synth --context stage=development # stage: "development" $ cdk synth -c stage=production # stage: "production" Tipsその3 さらに環境ごとの設定をする 先ほどの書き方の場合には、 if (stage == 'development') { としてましたので、developmentではないときにはproductionという扱いでした。prefixをロジックで追加できるのは良いですが、cdkコマンド実行時にスペルミスすると惨事になりそうです。また、他システムのAPI Keyなど環境ごとに値が全く異なるものもあるでしょう。 以下のように環境ごとの設定をcdk.jsonに記載します。 ... "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, "stage": "development", "development": { "domain": "app.example.com", "bucket_name": "nifty-engineering-example-bucket", "foo_system_api_key": "ABCDEF012345" }, "production": { "domain": "dev-app.example.com", "bucket_name": "dev-nifty-engineering-example-bucket", "foo_system_api_key": "XYZABC789012" } } その2で記載したコードは以下のようになります。こちらのほうがだいぶスッキリしますね。 import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as route53 from 'aws-cdk-lib/aws-route53'; import * as s3 from 'aws-cdk-lib/aws-s3'; export class ExampleAppStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const stage = scope.node.tryGetContext('stage'); const settings = scope.node.tryGetContext(stage); const hostedZone = route53.HostedZone.fromLookup(this, 'ExampleAppHostedZone', { domainName: settings.domain, }); const bucket = new s3.Bucket(this, 'ExampleAppBucket', { bucketName: settings.bucket_name, }); } } Tipsその4 リソースにタグを設定する コスト分析のためにコスト配分タグを使っていると思います。 CDKではStack内のリソースにまとめてタグを設定できます。 以下のようにタグを付けたいとします。 タグ名 値 application example application system example system 例によってcdk.jsonに以下のように書きます。 ... "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, "application": "example application", "system": "example system" } 以下のようにしてタグを設定できます。scopeはStackでもConstructでも指定できます。 Tags.of(scope).add(key, value) ExampleAppStack内のリソースすべてに同じタグを設定するには以下のように書きます。 import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; export class ExampleAppStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); cdk.Tags.of(scope).add('application', scope.node.tryGetContext('application')); cdk.Tags.of(scope).add('system', scope.node.tryGetContext('system')); ... } } Tipsその5 リソースにタグを設定する#2 常日頃から活発に開発をしているアプリケーションであれば良いですが、中には一度リリースした後にはほとんど触らないようなものもあります。1年後に手を入れることになり「ドキュメントやレポジトリはどこだっけ……」と調べて回るようなことになりがちです。 タグで各リソースにドキュメントやレポジトリのURLをつけておくと便利です。 タグ名 値 document https://notion.so/barcorporation/xxxxxxxxxx repository https://github.com/barcorporation/example-application タグのキーと値が上記の場合には、cdk.jsonには以下のように記述します。 ... "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, "document": "https://notion.so/barcorporation/xxxxxxxxxx", "repository": "https://github.com/barcorporation/example-application", } そして、CDKのStackのコードに以下のように書けば、Stack内の各リソースにドキュメントとレポジトリのURLがタグ付けされ、AWS管理コンソールから調査を始めたときにドキュメントに辿り着けるようになります。 import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; export class ExampleAppStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); cdk.Tags.of(scope).add('document', scope.node.tryGetContext('document')); cdk.Tags.of(scope).add('repository', scope.node.tryGetContext('repository')); } } We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering 明日は、 @rubihiko さんのSREでのリスク検討に関する記事です。 お楽しみに!
アバター