TECH PLAY

株式会社モバイルファクトリー

株式会社モバイルファクトリー の技術ブログ

222

駅メモ!チームエンジニアの id:yumlonne です。 この記事では駅メモ!で使っていた Memcached を廃止し Redis に統合した経緯や流れを紹介します。 記事内で提供するサンプルコードは、駅メモ!の実装に合わせ Perl となってます。 簡単なコードなので Perl に詳しく無い方でも十分理解できると思います。 KVS 統合の背景 駅メモ!は AWS を使ってサービスを提供しています。 統合前は Amazon ElastiCache で Memcached と Redis の両方を運用していました。 Memcached はプライマリノードのみ、Redis はプライマリノードとレプリカノードそれぞれ 1 台の構成でした。 それとほとんど同じ構成が他に 2 セットあるため、全体を見ると Memcached は 3 ノード存在していました。 Memcached は m6g.large を利用していたため、リザーブドノード料金で年間 3000 ドル以上のコスト削減が期待できました。 Memcached と Redis の使い分け 駅メモ!では Memcached と Redis を以下のように使い分けていました。 Memcached セッションデータ 揮発して良いデータ Redis ランキングデータ(sorted sets) 揮発してはいけないデータ ほとんどのデータは RDS で永続化しています ここではパフォーマンス面で揮発を許容できないことを指しています 統合先として Redis を選択した理由はいくつかありますが、決め手は低負荷かつ高パフォーマンスなランキング処理の実現に Redis が得意とする sorted sets 型を利用していたためです。 セッションデータの移行 ユーザに再ログインする手間をかけさせたくないため、Memcached のセッションデータだけは Redis に移行することとしました。 以下はセッションデータの移行の流れです。これによりメンテナンスなどを実施することなくセッションデータの移行を進められました。 アクセス時にセッションデータを Memcached から Redis に移行するモジュールを作成 (箇条書きの下にコードのサンプルを示します) 本番反映してからセッションの有効期限分の時間が経つまで様子を見る セッションの有効期限分の期間を空けることでアクセスのあるユーザのセッションデータは 1 で作成したモジュールによって移行されます アクセスのないユーザのセッションデータは移行されませんが、どちらにせよセッションデータの有効期限が過ぎているのでユーザには影響ありません Redis のセッションデータのみを参照するモジュールに差し替える 1 で作成したモジュールは Memcached も参照するので最終的には差し替える必要があります 以下は 1 で作成したモジュールのコードのサンプルです。 # セッションの登録 # 新規の登録は全てRedisに向ける sub set_session { my ( $key , $value ) = @_ ; # 有効期限を設定しシリアライズしてRedisに保存 $redis->set ( $key , $value , 'EX' , $session_expire ); } # セッションの取得 # Redisから取得できればそれを返し、そうでなければMemcachedから取得したものをRedisに登録して返す sub get_session { my ( $key ) = @_ ; my $session ; $session = $redis->get ( $key ); if ( defined $session ) { return $session ; } $session = $memcached->get ( $key ); if ( defined $session ) { set_session( $key , $session ); } return $session ; } Memcached を Redis に統合 Memcached を操作するコード全てを Redis に差し替えることを検討しましたが、差分が膨大になることでエンバグのリスク増え、動作確認およびレビュー範囲が広がってしまいます。そこで既存の Memcached を使うキャッシュモジュールと同じインターフェースを持つ Redis 実装のキャッシュモジュールを作成して差し替えることにしました。 Memcached を使うキャッシュモジュールで呼ばれていた関数を調査し、必要最低限の機能を実装しました。以下は Redis 実装のキャッシュモジュールの関数の一覧です。 set, set_multi, add, replace get, get_multi incr, decr delete, flush_all(Redis では flushdb に相当) これらの関数の実装では以下の苦労がありました。 Memcached と Redis のモジュールの振る舞いの違い Memcached のキャッシュモジュールは Cache::Memcached::Fast::Safe で、Redis のキャッシュモジュールは Redis をベースに作成しました。 それぞれのモジュールで同じインターフェースを提供することを考えていましたが、一部のコマンドの振る舞いにクセがあり、同じインターフェースにできなかった部分もあります。 例えば Memcached は decr で 10 から 9 のように桁が減ったとき、値を取得し直すと "9" ではなく "9 " のように 2 文字返してきます。 use Cache::Memcached::Fast::Safe; # ローカルのMemcachedに接続するインスタンスを作成 my $memcached = Cache::Memcached::Fast::Safe->new({ servers => [ { address => "localhost:11211" } ]}); $memcached->set ( "key" , 10 ); $memcached->decr ( "key" , 1 ); warn sprintf ( '"%s"' , $memcached->get ( "key" )); # => "9 " Perl では上記のような状態でも、数値として扱う分には問題にならないため、振る舞いの違いは許容できました。 Perl オブジェクトを素直に set できない Memcached にはフラグ機能があり、データとは別にメタ情報を保存することができます。一方で Redis にはこのような仕組みはありません。 数値や文字列を単純に保存する場合には問題になりませんが、構造化されたデータを保存する場合には少し困ります。取り出したデータがただのバイナリデータなのか、構造化されたデータをシリアライズしたものなのかを判断できないためです。 駅メモ!では数値や文字列の扱いが多いものの、一部の機能で Perl オブジェクトを保存していました。 上記を踏まえ、今回は以下の対応としました。 set する値が Perl のオブジェクト(ref した結果が true)なら Storable#nfeeze を使ってシリアライズする get した値を Storable#thaw でデシリアライズしてみて、エラーになったら thaw する前の値を返す 他の手法として、必ず一定のフォーマットのオブジェクトにしてからシリアライズしてセットするやりかたがあります。 この手法では Memcached のフラグ機能と同等の恩恵を得られますがデメリットもあります。ただの数値や文字列もシリアライズして保存すると、その値に対して incr などの直接の操作ができなくなり、redis-cli などで直接データを確認することも難しくなります。 上に書いた通り、駅メモ!では単純な数値や文字列を保存しているケースが多いため、この手法は採用しませんでした。 まとめとその後の展開 今回は駅メモ!における Memcached の廃止と Redis への統合についての手法を紹介しました。 費用面のコスト削減もさることながら、管理すべきミドルウェアが減ったことで運用面のコストも下がって一石二鳥だったと思います。 その後の展開として「Memcached と Redis の使い分け」 で触れたランキング処理について、最もデータ容量の大きいランキングを RDS に処理させるように改善し、Redis のスペックも下げることができました。こちらもいずれ記事にしたいと考えていますのでお楽しみに! 2023/01/29追記: MySQL でランキング処理を行うようにする仕組みの記事を公開しました! tech.mobilefactory.jp
アバター
こんにちは!この記事では Flutter でカメラを扱うアプリを作成する際の工夫について、紹介します。 はじめに 弊社で開発されている駅メモ!おでかけカメラ(以下「おでかけカメラ」)は 2022 年 11 月にリニューアルし、UI の刷新や動作不良の解消、機能の拡充を行いました。 内部的には、これまでの Unity 製だったおでかけカメラ(以下「旧おでかけカメラ」)を一度全て捨て、新しく Flutter で作り直すということをしています。 社内には Flutter に関する知見がほとんどなかったため、おでかけカメラのリニューアルは技術のキャッチアップから始まり、試行錯誤を重ねた開発となりました。 今回はその試行錯誤の中から、カメラの映像内にフレームやスタンプのような装飾を Widget として配置し、撮影した際の、写真と Widget を重ね合わせる工夫について説明します。 プレビューをそのまま撮影結果とすることの問題点 カメラ映像と Widget を重ね合わせて撮影する最も簡単な方法として、画面のスクリーンショットを撮影し、プレビュー領域以外の部分を削除することが考えられます。 この方法であれば、プレビューで表示されていたものが間違いなくそのまま撮影結果として得られますし、実装も単純になるのですが、大きな欠点があります。 それは、撮影画質がディスプレイ解像度に依存しているため、多くの場合カメラの性能を活かしきれず、残念画質な写真にしかならないと言うことです。 撮影後の画像サイズに合わせて Widget を再構築し、画像化する 上記の問題点を解消し、カメラの性能を最大限活かしつつプレビュー通りの撮影結果を得るために、おでかけカメラでは写真のサイズや縦横比と合うように一緒に撮影する Widget を画像化し、写真と合成するアプローチを採用しました。 具体的には画面に描画していないオフスクリーンな Widget Tree を構築できる BuildOwner というクラスを使用しています。 以下は撮影した写真のサイズに合わせた Widget を作成し、画像にしたものを取得するコードです。 import 'dart:ui' as ui; import 'package:image/image.dart' as img; Future < img. Image > getOverlayImage ( Size imageSize) async { // 描画、再描画を効率的に行えるようにする Class // ただし、ここでは Widget を画像化する機能を利用するために使っている final repaintBoundary = RenderRepaintBoundary (); // 描画する場所 // 写真のサイズで Widget が描画されるようにパラメータを渡している final renderView = RenderView ( window : ui.window, child : RenderPositionedBox (alignment : Alignment .center, child : repaintBoundary), configuration : ViewConfiguration ( size : imageSize, devicePixelRatio : 1.0 , ), ); // Widget Tree の描画を制御する Class final pipelineOwner = PipelineOwner (); pipelineOwner.rootNode = renderView; renderView. prepareInitialFrame (); // Widget Tree の構築、再構築を制御する Class final buildOwner = BuildOwner (focusManager : FocusManager ()); // 描画するもの // CameraOverlay() を画像化したものが最終的に欲しいもの final element = RenderObjectToWidgetAdapter < RenderBox > ( container : repaintBoundary, child : CameraOverlay (), ). attachToRenderTree (buildOwner); // BuildOwner に構築する Widget Tree のスコープを指定 buildOwner. buildScope (element); buildOwner. finalizeTree (); // PipelineOwner で描画 pipelineOwner. flushLayout (); pipelineOwner. flushCompositingBits (); pipelineOwner. flushPaint (); // 画像に変換して返却する final ui. Image widgetImage = await repaintBoundary. toImage (); final ByteData byteData = await widgetImage. toByteData (format : ui. ImageByteFormat .png); return img. decodeImage (byteData.buffer. asUint8List ()); } 簡単に説明すると、 BuildOwner で構築した Widget Tree を PipelineOwner で RenderView にレンダリングし、 RenderRepaintBoundary の機能を使って画像として書き出しています。 あとは以下の通り、画像化した Widget を撮影した写真と合成してあげれば、出来上がりです。 final img. Image cameraImage = takePicture (); final Size imageSize = Size (cameraImage.width. double (), cameraImage.height. double ()); final img. Image overlayImage = await getOverlayImage (imageSize); final img. Image compositeImage = img. drawImage (cameraImage, overlayImage); まとめ Flutter でカメラ映像と、その上に重ねて表示した Widget を合わせて撮影する際に、品質を落とさずプレビュー通りの結果を得るための工夫について紹介しました。 やりたいことは単純なのに意外と一工夫が必要ということで、詰まりやすいところなのかなと思いました。 カメラアプリを作る際にでも参考になれば幸いです。
アバター
こんにちは、駅メモ!でフロントエンドを良い感じにしたかったチームの id:yunagi_n です。 今回は、駅メモ!にて使用している Vue.js を 2 系から 3 系へあげて行くに当たって、採用した手法とマイグレーションプロセスについて紹介します。 今回、マイグレーションするに当たって、以下の要件がありました: 機能開発を止めてはいけない 駅メモ!では 6 月と 10 月に周年リリースがあり、それの開発を止めるわけにはいきませんでした もちろん、その間にあったイベントなどについても、開発は継続し続けています 多くのメンバーは割けない 基本はわたしが中心に、追加で 1 人〜2 人に手伝ってもらうことはありました また、参考のため、駅メモ!のフロントエンドの規模感を紹介しておくと: Vue コンポーネント数は 1500 コンポーネント fd --type file --extension vue | wc -l にて算出 *1 フロントエンドのコード全体は 4700 ファイル、16 万行 tokei . にて算出 *2 といった感じでした。 規模感で言えば超大規模、またフロントエンドにおいてはテストなども一切存在していなかったため、かなり厳しい作業となることが予想されました。 ただ、駅メモ!では、歴史的な理由から外部の依存パッケージが少なく、サードパーティーパッケージの更新による影響は避けられました。 しかしながら、パッケージ数による影響は無いですが、 Babel や Webpack などの依存関係は 導入当時以降アップデートされておらず (導入当時は、つまりサービス開始日より前です)、そのままでは 2023 年に開発されたパッケージの一部はバンドル出来ない状態でした。 また、今回は Vue.js 公式から提供されている Migration Build は使用していません。 これは、超大規模となった場合、一度入れてしまったライブラリは抜くことが困難であることや、かえって効率が低下する可能性があったことからです。 これらの状態から、 Vue 3 環境へとマイグレーションするため、以下のような手法を採用しました。 新しく Vue.js 3.x で起動可能なエントリーポイントを別パッケージとして作成 新しいエントリーポイントが起動でき次第、各種ロジックについて適切にパッケージとして切り出す (monorepo) 各パッケージは main と exports フィールドを用いてビルド成果物を出し分ける main には古いエントリーポイントを対象としたコードを (Webpack が古すぎて exports を認識できないため) exports には新しいエントリーポイント、もしくは新しいパッケージを対象としたコードを 徐々に現在のエントリーポイントに属する部分から新しいパッケージへと分離する 純粋なロジックは単純な JavaScript パッケージとして Vue に関わるものはコンポーネントライブラリとして 最終的には現在のエントリーポイントは廃止し、削除する といった形です。 それぞれ、なぜこのような手法を取ったのか詳しく補足していきます。 新しく Vue.js 3.x で起動可能なエントリーポイントを別パッケージとして作成 これは、以下の理由からになります 現在のエントリーポイントにあたる Babel や Webpack では、まずアップグレードプロセスが必要になる ただ、アップグレードについては過去業務委託の方に調査をお願いしたが、現実的な時間では不可能という結論となった フロントエンドの開発体験が著しく悪い (初期開発ビルドまで数分、リロードまでも数十秒) という話があり、マイグレーションするにあたって障壁となる 時間的制約が厳しい中で、1 イテレーションに対して数十秒かかるのは進行スピードに大きな影響を与える これらの問題を解決するため、既存のエントリーポイントに追加や置き換えをするのでは無く、新たにパッケージを切り出すことで、上記問題を解決することが可能でした (少々力尽くですが......)。 また、このタイミングで今までは超巨大な 1 パッケージだったのを monorepo として切り出すため、以下のような整備も行いました。 Turborepo の導入 Yarn Workspaces の導入 monorepo としては Yarn Workspaces を入れるのは良く聞きますが、 Turborepo を入れたのはなかなか当時としては珍しい構成だった記憶があります。 Turborepo を導入したきっかけとしては、依存グラフを用いて必要なパッケージだけでコマンドが実行できる点、キャッシュ機能により変化がないパッケージについてはビルドがされない点などです。 新しいエントリーポイントが起動でき次第、各種ロジックについて適切にパッケージとして切り出す こちらは、以下の理由があります。 上記に繋がるが、 Babel が古すぎて ES2015 の一部文法のみが使用可能であり、生産性が低い 現状のエントリーポイントにあたる部分では、循環参照などが当たり前に起きており、バンドル時に明らかに不要なファイルなども含まれていた これについては、パッケージを分けたことで、単純に新しい文法とスピード感あるイテレーションをやりたい、の他にも以下の利点がありました。 Turborepo を採用したので、パッケージを適切に分けることで、ビルドキャッシュがうまく使える パッケージマネージャーレベルで循環参照を防げる 完全な形で TypeScript の導入が出来る 妥協しない形で ESLint や Stylelint 、 Prettier の導入が出来る とくに、前者は今フロントエンドのビルドにおいて、プロダクションビルドを行った際 10 分程度かかっていた時間が大幅に削減できます。 そして、ここで分けたパッケージについては GitHub Package Registry に Publish しており、そちらを利用することであらかじめビルドしたパッケージも使えます。 また、いままで駅メモ!のフロントエンドでは、今年中頃まで ESLint も Stylelint もまともに運用されていませんでした (もちろん Prettier もありません)。 それらについて、さすがに無いのはコードクオリティ面で問題があるので導入はしたのですが、かなり妥協した設定です。 しかしながら、パッケージとして切り出した部分については、厳密な形で TypeScript や ESLint, Prettier ,必要であれば Stylelint も導入できます。 これによって、副作用的に新しく書かれた部分についてはコードクオリティ面での向上も図れました。 徐々に現在のエントリーポイントに属する部分から新しいパッケージへと分離する これは開発が常に続いているといった前提によるものです。 例えば、いまわたしがこの記事を書いている時点でもフロントエンドを含む新規開発が行われており、それらを新しく Vue3 で書いて・書き直して欲しい、というのは納期や教育コストなどを考えた場合現実的ではありません。 そのため、現在のエントリーポイントについてはそのままで、 Vue3 で書かれたパッケージを vue-demi や社内で新たに作成したマイグレーション用パッケージを用いて Vue 2/3 両対応としてビルド可能にし、それを上記パッケージ経由で使用することで、極力同じ使用感で Vue3 パッケージを使用できるようにしています。 例えば、以下のコードは書き換え前のコードです。 < template > < button v-se-player> ... </ button > </ template > < script > import sePlayer from 'example/directives/v-se-player' ; // Vue2 export { directives: { sePlayer } } </ script > これらは、次のように書き換えるだけで、 Vue3 にも対応したコードに出来ます。 < template > < button v-se-player> ... </ button > </ template > < script > import { vSePlayer } from '@example-scope/directives' ; // Vue3 export { directives: { sePlayer: vSePlayer } } </ script > インポート先を変えるだけですね。簡単です。 このような形で、既存モジュールをパッケージとして切り出し、かつ同じ形式で使えるようにすることで、学習コストがほぼ無い状態のままで移行ができます。 また、新規開発部分については、既存のモジュールを使いつつインポート先を変えるだけで Vue3 にも対応できるので、今後のコストが下げられます。 まとめ ということで、現在対応している駅メモ!における Vue.js のマイグレーションについて採用した手法とそのプロセスについての紹介でした。 わたしはこの仕事を最後に退職してしまうので、続きは残った人にお願いする形ですが、また次回の id:yunagi_n の記事をお楽しみください。 *1 : fd はファイルシステムのエントリーを検索するツールです。 *2 : tokei は指定ディレクトリー内コードの統計データを出してくれるツールです。
アバター
こんにちは!エンジニアの id:mkan0141 です! モバイルファクトリーでは「シェアナレ」という 1 日の業務時間のうち 1 時間であれば自習・勉強に使って OK という制度があります。 今回はその制度を利用して 8 月に「朝Rustもくもく会」というものを開催したので紹介します。 朝 Rust もくもく会とは 8 月の平日毎朝 08:30~09:30 に Rust に関することをもくもくと勉強・作業する会です。 目的は「Rust を触ったことがない・少し触ったけど続かなかった人に Rust をちゃんと勉強する機会を作る」と「早起きして健康になる」です。参加するだけで Rust の知識と健康が手に入ります。素敵ですね。 各自やることに関しては特に縛りはなく、Rust であればなんでも OK という方針にしました。一部ですが参加者が作業していた内容を以下にまとめます。 読書 The Rust Programming Language 日本語版 Tour of Rust 実践 Rust プログラミング入門 The Little Book of Rust Macros 作業 Discord Bot の開発 自作ツールのアップデート なぜ朝にするの? もちろん健康になりたいからというのはありますが、真面目な理由としては静かで集中しやすい環境だと思ったからです。 時間帯にもよりますが、午後はメンションが多かったり、優先度の高い対応が起きてしまったりすることが多い印象です。そのため、勉強会中に少し気が逸れたり、参加することができなかったりします。もちろん業務優先なので仕方がないのですが、せっかくやるなら集中してやりたい!というので、比較的これらのことが起こりづらい早朝を選んで開催してみました。 進め方 今回、初めてもくもく会を主催したので他のもくもく会を参考に以下のように決めました。 主催者と参加者に負担がかからないようなゆるい会になるのが目標でした。 各自やることを宣言して作業(50 分間) 各自の知見の docbase に記入・共有(10 分間) 感想 初もくもく会主催だったのもあり、途中で参加者 0 人にならないか少し不安になりながら開催したのですが、ほぼ参加者が絶えることなく開催できてほっとしています。 早朝開催に関しては、やはり早い時間のもくもく会は予想通り静かで集中しやすい環境になっていたので個人的にはかなりアリだと思いました。ただ、振り返ってみると参加メンバーの大半は普段から早めに出勤している人だったので、実は夕方開催の方が参加しやすく参加者も多くなっていたかもしれないです。 また、もくもく会の進行に関しては、知見の共有の時間を設けたのは良かったと思いました。 各々の視点で理解した内容や Rust の躓いた話が聞けて、自分の理解が合っているかや躓いたポイントについて他の人が補足したりと Rust の理解がさらに深まったかなと思います。 終わりに 朝 Rust もくもく会について紹介しました。 朝にする勉強会は健康になれますし学びも得られて個人的には満足度が高かったです。 ぜひ、みなさんも朝もくもく会を企画してみてはいかがでしょうか!
アバター
駅メモ!開発基盤チームの id:xztaityozx です! 皆さんは Perl を書いていますか?モバイルファクトリーが長く提供しているサービスなどでは、バックエンドが Perl で書かれています。 しかしながら、自分は普段インフラ領域をやらせてもらっているということもあり、Perl で新機能開発をする!といった機会がそんなにありません。 せっかく Perl だらけの環境にいるのに、あんまり Perl に触れられないのはもったいないな〜と思い、今年のゴールデンウィークは PPI を使ったメタプログラミングで遊んでいました。 metacpan.org で、ちょっと遊んでいたら Perl のパッケージ情報を使って C#のクラスを吐き出すプログラムができたので記事にしてみました。自由研究発表という感じです。 変換先に C#を選んだのは C#が大好きだからです。 Perl パッケージを C#クラスにする例 研究をしていたリポジトリは以下のものです。リポジトリ名気に入ってます。ガッっと書いたのでめちゃめちゃ雑です。 github.com README にも書いてあることですが、以下のような Perl パッケージがあるとき package My::Namespace::B ; use strictures 2 ; use Function::Parameters; use Function::Return; use Types::Standard -types; use Data::Validator; use Mouse; has name => ( is => 'ro' , isa => Str, default => 'this is name' ); has age => ( is => 'ro' , isa => Int, required => 1 ); has union => ( is => 'ro' , isa => Str|Int, required => 1 ); has dict => ( is => 'ro' , isa => Dict[ name => Str, age => Int], required => 1 ); no Mouse; __PACKAGE__ ->meta->make_immutable ; sub a { return 10 ; } fun b() :Return(Int) { return 10 ; }; method c() :Return(Int) { return 10 ; }; method d(Str $str , $x , Int $y //= 1 ) { return 10 ; }; sub e { my $rule = Data::Validator->new( str => { isa => Str }, x => { isa => Any }, y => { isa => Int, default => 1 }, ); $rule->validate ( @_ ); return 10 ; } sub f :Return(Str, Int) { my ( $self , $str , $x , $y ) = @_ ; return [ $str , $x + $y ]; } sub g { return ; } sub h { my $a = shift ; return 1 ; } 1 ; 以下のような C#コードが生成されます。基礎部分は NJsonSchema を使って JSON Schema から生成し、メソッド部分はシグネチャだけ移植したようなコードになります。そこそこ長くなるので一部省略しております。 //---------------------- // <auto-generated> // Generated using the NJsonSchema v10.9.0.0 (Newtonsoft.Json v9.0.0.0) (http://NJsonSchema.org) // </auto-generated> //---------------------- namespace My.Namespace { #pragma warning disable // Disable all warnings [System.CodeDom.Compiler.GeneratedCode( "NJsonSchema" , "10.9.0.0 (Newtonsoft.Json v9.0.0.0)" )] public partial class B { /// < summary > /// original Perl type: Int /// </ summary > [System.Text.Json.Serialization.JsonPropertyName( "age" )] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] public int Age { get ; set ; } /// < summary > /// original Perl type: Dict[age = & gt ; Int,name = & gt ; Str] /// </ summary > [System.Text.Json.Serialization.JsonPropertyName( "dict" )] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] public Dict Dict { get ; set ; } = new Dict(); /// < summary > /// original Perl type: Str /// </ summary > [System.Text.Json.Serialization.JsonPropertyName( "name" )] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] public string Name { get ; set ; } = "this is name" ; /// < summary > /// original Perl type: Str|Int /// </ summary > [System.Text.Json.Serialization.JsonPropertyName( "union" )] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.Never)] public Union Union { get ; set ; } private System.Collections.Generic.IDictionary< string , object > _additionalProperties; [System.Text.Json.Serialization.JsonExtensionData] public System.Collections.Generic.IDictionary< string , object > AdditionalProperties { // ... 省略 } /// < summary ></ summary > /// < returns ></ returns > public object a() { throw new NotImplementedException(); } /// < summary ></ summary > /// < param name = "str" > original Perl type: Str </ param > /// < param name = "x" > original Perl type: Any </ param > /// < param name = "y" > original Perl type: Int </ param > /// < returns ></ returns > public object e( string str, object x, int y = 1 ) { throw new NotImplementedException(); } /// < summary ></ summary > /// < returns > original Perl type: Str, Int </ returns > public object f() { throw new NotImplementedException(); } /// < summary ></ summary > /// < returns ></ returns > public void g() { throw new NotImplementedException(); } /// < summary ></ summary > /// < param name = "a" > original Perl type: Any </ param > /// < returns ></ returns > public object h( object a) { throw new NotImplementedException(); } /// < summary ></ summary > /// < returns > original Perl type: Int </ returns > public int b() { throw new NotImplementedException(); } /// < summary ></ summary > /// < returns > original Perl type: Int </ returns > public int c() { throw new NotImplementedException(); } /// < summary ></ summary > /// < param name = "str" > original Perl type: Str </ param > /// < param name = "x" > original Perl type: Any </ param > /// < param name = "y" > original Perl type: Int </ param > /// < returns ></ returns > public object d( string str, object x, int y) { throw new NotImplementedException(); } } [System.CodeDom.Compiler.GeneratedCode( "NJsonSchema" , "10.9.0.0 (Newtonsoft.Json v9.0.0.0)" )] public partial class Dict { // ... 省略 } [System.CodeDom.Compiler.GeneratedCode( "NJsonSchema" , "10.9.0.0 (Newtonsoft.Json v9.0.0.0)" )] public partial class Union { // ... 省略 } } やっていること このリポジトリでやっているのはざっくりいうと以下の 2 つのことだけです。 PPI とかを使って Perl パッケージ情報を取り出して JSON に書き出す C#で JSON を読んで C#の AST を構築し、できたものをファイルに書き出す 同時に 2 つの AST を読むことになったので結構混乱しました。どちらの言語もむずいです。 1. PPI とかを使って Perl パッケージ情報を取り出して JSON に書き出す 今回の研究で取り出したいと思ったのは以下の 5 つです。 パッケージ名 名前空間 親パッケージ Mouse のプロパティ サブルーチンやメソッドのシグネチャ情報 1,2 は PPI で簡単に取り出せます。具体的には以下のように書けます。 my $package_nodes = $document->find ( 'PPI::Statement::Package' ); foreach my $package_node ( @{$package_nodes} ) { my @namespace = split ( / :: /x , $package_node->namespace ); my $name = pop @namespace ; } 3 の親パッケージは @ISA を見るだけ、4 のプロパティは PPI で宣言を探しつつ、Mouse のメタ情報から情報を取り出せばよいですね。 # 親パッケージを取り出す no strict 'refs' ; my @superclasses = @{ $class_name . '::ISA' } ; use strict 'refs' ; # プロパティの宣言を探して、プロパティ名を取り出す。 my $has_statements = $ppi_document->find ( sub { my ( $root , $node ) = @_ ; if ( $node->isa ( 'PPI::Token::Word' ) && $node->content eq 'has' ) { return 1 ; } return 0 ; } ); # メタ情報から名前が一致するプロパティの情報を取り出す # こうしておくと継承してたりMouseがはやしたりしたやつを省ける(別の方法はないのでしょうか…?) my $meta = Mouse::Util::get_metaclass_by_name( $package_statement->namespace ); my @properties ; foreach my $has_statement ( @{$has_statements} ) { my $property_name = $has_statement->snext_sibling->string ; my $attr = $meta->get_attribute ( $property_name ); push @properties , $attr ; } ここまでは順調ですが、問題は 5 のサブルーチン・メソッドのシグネチャ情報ですね…。 Function::Parameters のメタ情報からシグネチャ情報を取り出してみる Perl にはサブルーチンやメソッドのシグネチャをサポートするようなモジュールが沢山あります。たとえば Type::Params や Function::Parameters などですね。 こういうモジュールは大体の場合メタ情報が存在するため、そこから欲しい情報を取り出すことができます。 # Function::Parametersの例 use strictures 2 ; use Function::Parameters; use Types::Standard -types; method hoge( $class : Int $i ) { return $i ; } # Function::Parameters::Info が返される。引数のリストなどが分かる my $info = Function::Parameters::info(\ &hoge ); つまり、PPI でサブルーチン・メソッドを見つけ次第、メタ情報からシグネチャ情報を取り出せば良さそうです。 しかしながら、先程述べた通りこういうモジュールは沢山あるので全て対応するのは難しいです。そこでまずは Function::Parameters と Function::Return だけ注目することにしました。Function::Return は最近駅メモ!のコードでも使われるようになったので、これを機に仲良くなっておこうと思って選びました。 普通のサブルーチンからもシグネチャ情報を取り出したい Function::Parameters や Function::Return を使って書かれているサブルーチンはいいのですが、以下のようなよくあるサブルーチンはどうしましょう。 sub hoge_sub { my $hoge_arg = shift ; return $hoge_arg ; } 最初、こういうのについては諦めようと思っていたんですが、PPI で遊んでいるうちに「なんとかなりそうだな」と思いました。 値の受け取りは、大体の場合以下のうちのどれかのパターンになるのでは?と考えたからです。(もちろん網羅できてるとは思っていません) my $arg = shift ; my ( $arg1 , $arg2 ) = @_ ; my ( $arg1 , $arg2 , $arg3 ) = ( shift , shift , shift ); これらの式自体は PPI::Statement::Variable になります。 children メソッドを呼び出すと、そこにぶら下がっている子を見ることができます。 以下の例は my $arg = shift の children です。 [0] my (PPI::Token::Word), [1] (PPI::Token::Whitespace), [2] $arg (PPI::Token::Symbol), [3] (PPI::Token::Whitespace), [4] = (PPI::Token::Operator), [5] (PPI::Token::Whitespace), [6] shift (PPI::Token::Word), [7] ; (PPI::Token::Structure) なんかなんとかなりそうな気がしませんか? そうですね。右辺値の PPI::Token::Word が shift か @_ な PPI::Statement::Variable から、左辺値の PPI::Token::Symbol を取り出せば引数名がわかりますね。 残念ながら型はわからないので全部 Any になってしまいますが、今回は引数名だけでも分かればヨシ!ということでこの方針を採用しました。 Data::Validator からも引数情報を取り出したい Data::Validator は引数のバリデーションをしてくれるモジュールです。引数名、型、制約を指定しておくことで、実際に渡ってきた値を評価してくれるものです。そうですね。これも引数の情報なのです。 メタ情報を取り出せるような仕組みがあればよかったのですが、自分が調べた限りでは見つけられませんでした。なので PPI でパースしました。機能の網羅はできてないかも…。 JSON に書き出す ここまでで取り出した情報を JSON に書き出してみます。フォーマットを JSON Schema や ProtoBuf みたいなスキーマ定義に寄せられればよかったのですが、メソッド情報を格納するところがなくて独自になってしまいました。具体的には以下のようなフォーマットです。 { " name ": " パッケージ名 ", " namespace ": [ " 名 "," 前 "," 空 "," 間 " ] , " schema ": { パッケージを表す JSON Schema } , " methods ": [ { " arguments ": [ { " name ": " 引数の名前 ", " required ": true , " type ": { " description ": " 元々の型がなんだったかの説明 ", " type ": " stringとかnumberみたいな型の名前 " } } , { ... } , { ... } , ... ] , " declare_type ": " subとかfunとかサブルーチン定義のキーワード ", " name ": " 関数名 ", " returns ": { " type ": " number " } } , { ... } , { ... } , ... ] } メソッド情報が必要ないのであれば、 schema メンバーだけ使えば良いという親切設計です(?) 2. C#で JSON を読んで C#の AST を構築し、できたものをファイルに書き出す こちらは NJsonSchema を使ってクラスの雛形を作り、 Roslyn API を使って雛形にメソッド定義を追加していくという感じです。 JSON を読んで展開していくだけなので、PPI の時ほど難しいことはありません。 var csharpGeneratorSetting = new CSharpGeneratorSettings { JsonLibrary = CSharpJsonLibrary.SystemTextJson, Namespace = string .Join( "." , package.Namespace), }; var schema = await JsonSchema.FromJsonAsync( "schemaプロパティの値" ); var csharpGenerator = new CSharpGenerator(schema, csharpGeneratorSetting); var file = csharpGenerator.GenerateFile() ?? throw new FileNotFoundException(); using var stream = new StringReader(file); var syntaxTree = CSharpSyntaxTree.ParseText( await stream.ReadToEndAsync()); var root = await syntaxTree.GetRootAsync(); var targetClassDeclarationSyntax = root.DescendantNodes() .OfType<ClassDeclarationSyntax>() .FirstOrDefault(syntax => syntax.Identifier.ValueText == package.Name) ?? throw new NoNullAllowedException( $" { package.Name } が見つかりませんでした" ); // targetClassDeclarationSyntax が schema プロパティから生成したクラス // ここに methods プロパティの情報を使ってメソッド定義を追加していく // 全部書くと長いので今回は省略…。リポジトリを見てください。 var newClassDeclarationSyntax = AddMethodDeclarationSyntax(); var newRoot = root.ReplaceNode(targetClassDeclarationSyntax, newClassDeclarationSyntax); var result = syntaxTree.WithRootAndOptions(newRoot, syntaxTree.Options); まとめ 今回は自由研究として、PPI を使った Perl パッケージ情報の解析をやってみました。解析結果を使って Perl パッケージを C#のクラスに変換しました。 結果としてそれっぽいクラスを生成できました。いまのところ今回作った生成機能をどこかで使う予定はないですが、解析結果を使えば LSP を更に便利にできるのでは?と考えています。 メタプログラミングはやはり楽しいですね。また、まとまった時間があれば研究してみたいなと思いました。 以上です。
アバター
こんにちは、エンジニアの id:kaoru-k_0106 です。 駅奪取のサブスク機能である「 駅奪取er定期券 」は、App Storeのサーバ通知の実装の際に App Store Server Notification V2 を用いました。 他の言語での Server Notification V2 の実装例は見つかりますが、Perl のものはありませんでした。 そこで、今回は Perl での検証部分の実装方法について触れようと思います。 App Store Server Notification V2 について V1 のときは、通常の App 内課金と同じように、サーバ通知で送られてきたレシートを、App Store サーバの verifyReceipt エンドポイントに送信して検証する必要がありました。 参考: App Storeを使用してレシートを検証する - 日本語ドキュメント - Apple Developer ですが、V2 では署名されたレシートが送られてくるようになったため、App Store サーバに問い合わせる必要がなくなり、アプリケーションサーバで検証処理が完結します。 また、通知タイプが追加されたりと、サブスクリプションの状態判定が V1 よりも簡単にできるようになっています。 2023 年 6 月に V1 のサーバ通知は Deprecated になったので、新規で採用することはないと思いますが、V2 によって実装がしやすくなったと思います。 詳しくは Apple Developer Documentation を参照してください。 参考: App Store Server Notifications | Apple Developer Documentation Server Notification V2 の JWS を Perl で検証する V2 のサーバ通知は、JWS (JSON Web Signature) で送られてきます。 この JWS の署名を検証することで、レシートの検証が完了します。 それでは、サーバ通知を受け取ってから、JWS の署名検証が完了するまでの手順を順に追っていきます。 1. JWS のヘッダから証明書を取り出す JWS を扱うモジュールは CPAN でいくつか見つかりますが、一番更新が新しい Crypt::JWT を使うことにしました。 ペイロードの署名検証に必要な証明書は、ヘッダの x5c フィールドに含まれていますが、このモジュールは x5c フィールドに対応していません。 そのため、証明書を手動で取り出す必要がありますが、JWS のフォーマットはシンプルなので簡単にできます。 JWS は、以下のように「ヘッダ」「ペイロード」「ヘッダとペイロードの署名」が . (ピリオド)で繋がれた形になっています。 ${header}.${payload}.${signature} これを split すればヘッダを取り出せます。 ただし、JWS の各パートは通常の Base64 ではなく、URL Safe な Base64 (base64url) でエンコードされているので、その点は注意が必要です。 # 証明書が欲しいのでdecode_jwtする前にヘッダを取得 my ( $header ,,) = map { decode_base64url( $_ ) } split ( / \. / , $token ); ヘッダを base64url でデコードすると JSON になっており、そのうちの x5c フィールドに以下の 3 つの証明書が含まれています。 サーバ証明書: 署名に使ったサーバ証明書 中間証明書: サーバ証明書を署名した公開鍵を含む証明書 ルート証明書: 中間証明書を署名した公開鍵を含む証明書、自己証明書 参考: JWSDecodedHeader | Apple Developer Documentation 証明書は、通常の Base64 でエンコードされた DER 形式で格納されています。 次の証明書チェーンの検証で PEM 形式の証明書が必要になるので、ここで変換しておきます。 PEM 形式は DER 形式の証明書を Base64 でエンコードしたものにヘッダとフッタをつけたものなので、簡単に変換ができます。 ただ、 RFC 7468 に、最終行以外はちょうど 64 文字にする必要があると書かれていたので、仕様に準拠させるため改行を入れるようにしました。 # ヘッダのx5cから証明書を取り出してPEMにする} my @cert_chain = map { _base64encoded_der_to_pem( $_ ) } @{ decode_json( $header ) ->{ x5c } } ; sub _base64encoded_der_to_pem { # certはbase64エンコードされたDER my $cert = shift ; # RFC 7468 の仕様に準拠させるため、64文字ごとに改行を入れる $cert =~ s/ (.{64}) / $1 \n /g ; # ヘッダとフッタをつければPEMになる return "-----BEGIN CERTIFICATE----- \n $cert \n -----END CERTIFICATE----- \n\n " } 2. 証明書チェーンの検証 次に、証明書チェーンが正しいものか検証します。 証明書チェーンの検証には Crypt::OpenSSL::CA を使いました。 また、検証には Apple のルート証明書が必要であるため Apple PKI から「Apple Root CA - G3 Root」をダウンロードして、サーバ内に配置しました。 X.509 証明書についての詳しい解説は割愛しますが、ルート証明書は自己証明書なので、ローカルに保存したルート証明書を用いることで検証できます。 x5c フィールドから取り出した証明書チェーンとサーバ内に配置したローカルのルート証明書を用いて以下の流れで検証を行います。 中間証明書でサーバ証明書を検証 チェーン内のルート証明書で中間証明書を検証 ローカルのルート証明書でチェーン内のルート証明書を検証 中間証明書も Apple PKI で公開されているため、中間証明書の検証までで完了とすることもできますが、期限がルート証明書より短く、管理コストがかかるため、ルート証明書で検証するのがいいと思います。 検証に失敗した場合は例外が発生するので、その場合は処理を中断させます。 コードは以下のようになります。 # ローカルの証明書を読み込んでPEMに変換する my $root_cert = Crypt::OpenSSL::X509->new_from_file( '/path/to/AppleRootCA-G3.cer' , Crypt::OpenSSL::X509::FORMAT_ASN1)->as_string(Crypt::OpenSSL::X509::FORMAT_PEM); my @certs = map { Crypt::OpenSSL::CA::X509->parse( $_ ); } ( @cert_chain , $root_cert ); for my $i ( 0 .. ( $#certs - 1 ) ) { ( $certs[$i] )->verify(( $certs[$i + 1 ]->get_public_key )); } 3. JWS のデコード 証明書が正しいことを検証できたので、 Crypt::JWT の decode_jwt で署名を検証しつつデコードして完了です。 # 証明書チェーンの先頭の証明書で署名検証 my $decoded_payload = decode_jwt( token => $token , key => \ $cert_chain[ 0 ] ); おわりに Ruby や Go など他の言語では、OpenSSL 周りが標準機能として提供されていますが、Perl では外部モジュールを使う必要があり、少し手間がかかりました。 とはいえ、ひととおりは必要なモジュールが揃っているので、問題なく実装ができます。 また、実装に当たっては、他の言語での実装がとても参考になりました。ありがとうございました。 参考 App Store Server Notifications Version 2(StoreKit 2)の JWS を検証する | by Taiga ASANO | MIXI DEVELOPERS App Store Server Notifications V2をGoで検証するOSSを作った | by ぺりー | Eureka Engineering | Medium 初心者でもわかるiOSサブスク課金のサーバ側の実装!App Store Server Notifications Version 2(StoreKit 2)のJWS検証と判定方法を解説! - Qiita
アバター
こんにちは!BC チームでエンジニアをしている id:d-kimuson です。 今回は外部リレーションに関して型安全性の乏しい TypeORM の Data Mapper パターンを独自のユーティリティ型を使ってちょっとマシにする方法を紹介します。 前提: TypeORM の外部リレーションについて TypeORM では ManyToMany 等のデコレータを使ってスキーマに Foreign Key を書くことができます。 // 公式ドキュメントのサンプルです @Entity () export class Category { @PrimaryGeneratedColumn () id: number @Column () name: string @ManyToMany ((type) => Question , ( question ) => question.categories ) questions: Question [] } @Entity () export class Question { @PrimaryGeneratedColumn () id: number @Column () title: string @Column () text: string @ManyToMany ((type) => Category , ( category ) => category.questions ) @JoinTable () categories: Category [] } そして、実際にデータをクエリビルダや リポジトリ のメソッドから取ってくるに当たって、外部リレーションを一緒に取ってくるには複数のやり方がサポートされています。 ① クエリレベルでリレーションを選択する 最もオーソドックスなやり方でクエリを叩くときにリレーションを選択します。 declare const repository: Repository < Question > // リポジトリメソッド const question = await repository.findOneOrFail ( { relations: [ "categories" ] , where: { id: 1 } , } ) // relation に指定した categories は自動的に Join されて取得されます // クエリビルダ const question = await repository .createQueryBuilder ( "question" ) .innerJoinAndSelect ( "question.categories" , "categories" ) .where ( "question.id = :id" , { id: 1 } ) .getOneOrFail () // joinAndSelect に指定した categories は自動的に Join されて取得されます innerJoinAndSelect や relation によって明示することでリレーションのあるレコードを拾ってきてくれます。 ② スキーマレベルで eager: true を設定し、選択しなくても勝手に Join させる @Entity () export class Question { // ... @ManyToMany ((type) => Category , ( category ) => category.questions , { eager: true , } ) @JoinTable () categories: Category [] } eager: true を指定しておくと relations に指定せずとも自動的に Join されて取得されます。 // リポジトリメソッド const question = await repository.findOneOrFail ( { // relations: ['categories'], // なくても自動的に JOIN される where: { id: 1 } , } ) // クエリビルダ const question = await repository .createQueryBuilder ( "question" ) // .innerJoinAndSelect('question.categories', 'categories') // なくても自動的に JOIN される .where ( "question.id = :id" , { id: 1 } ) .getOneOrFail () 明示せずとも Join して取ってこれるので便利ですが、必要ないケースでも必ずリレーションを取ってきてしまうという欠点があります。 ③ スキーマレベルで lazy relation を設定し、参照時にクエリを発行する @Entity () export class Question { // ... @ManyToMany ((type) => Category , ( category ) => category.questions ) @JoinTable () categories: Promise < Category [] > } スキーマで Promise でリレーションを宣言すると、lazy relation となります。 参照したタイミングでクエリが走り、await してあげることでリレーション先のデータを取得できます。 declare const question: Question await question.categories // 参照したタイミングでクエリが発行されて取得できる 外部リレーションを取得する方法としては、以上の 3 種類が存在します。 弊チームでは ② Eager Relation: 必要ないユースケースでもリレーションを取ってくることになってしまうため基本使わない ③ Lazy Relation: 弊チームでは TypeORM をリポジトリパターンとして利用しているので、参照時にクエリが発行される Lazy Relation のアプローチは取りたくない ことが理由で基本的には ① のアプローチのみを利用しています。 この記事は ① のアプローチを使用している(eager relation や lazy relation を利用しない)ことが前提となります。 問題: 都度リレーション指定だと型安全性がない ② や ③ のアプローチで外部リレーションを取ってきている場合や、Active Record パターンを使っている場合は都度フェッチしたり必ず Join されていたりするので問題にならないのですが、Data Mapper で ① の都度 Join の利用だと、外部リレーションに関して型安全性の問題があります。 これは Join するかどうかがクエリビルダやリポジトリメソッドのオプションの指定に左右されますが、返されるデータの型はスキーマクラスの型で固定になってしまうからです。 例えば const question = await repository .createQueryBuilder ( "question" ) .innerJoinAndSelect ( "question.categories" , "categories" ) .where ( "question.id = :id" , { id: 1 } ) .getOneOrFail () question.categories // Categories[] 型になっていて、実際にアクセスもできる のように使うリレーションが Join 済みであれば問題ありませんが const question = await repository .createQueryBuilder ( "question" ) .where ( "question.id = :id" , { id: 1 } ) .getOneOrFail () question.categories // Categories[] 型になってるのに、アクセスすると undefined を受け取ってしまう このように Join がされていない場合には型が存在するので取得済みだと思ったデータにアクセスすると実際には undefined を受け取ってしまうことになります。 これだと例えば question を引数に取るサービスメソッドを用意したときに Join なしで取得していても、Join が前提になるメソッドへ渡せてしまうことになります。 const question = await repository .createQueryBuilder ( "question" ) .where ( "question.id = :id" , { id: 1 } ) .getOneOrFail () // categories は拾ってきていない const someMethod = ( question: Question ) => { // categories を使ってゴニョゴニョする const x = question.categories.filter ( ... ) // Uncaught TypeError: Cannot read properties of undefined (reading 'filter') } someMethod ( question ) // 渡せてしまう... この問題があるため以前は question: Question // categories の Join が前提 みたいなコメントを使って意図を伝えるようにしており、辛い状態でした。 ユーティリティ型で解決する この問題をちゃんと型チェックで気付けるようにする回避策として独自の型ユーテリティを用意しています。 本当はライブラリ側で良い感じに吸収してくれて型が付くと嬉しいのですが、現状ではできていないので独自のユーティリティ型を用意してデータフェッチ時に型を書くことで対応しています。 アプローチとしては スキーマクラス型からリレーションについて型安全な型に変換するユーティリティ型( StrictEntity )を用意し、データフェッチや制約として関数の引数で使うときに StrictEntity を通す StrictEntity はスキーマクラスからリレーションのキーを除外する リレーションのキーは string , number , bigint , boolean , Date 以外が値にあるもの Join されている対象は Generics で明示的に指定する のような形で実現しています。 実装は以下のようになってます。 type TypeOrmPrimitive = string | number | bigint | boolean | Date export type PlainObject < Entity > = { [ K in Exclude <keyof Entity , MethodKeys < Entity >> ] : Entity [ K ] } /** * @desc TypeOrm の Entity からリレーションを持たないキーを取り出す Utility */ export type PrimitiveKeys < T > = keyof { [ K in keyof PlainObject < T > as NonNullable < T [ K ] > extends TypeOrmPrimitive ? K : never ] : T [ K ] } /** * @desc Class のメソッドのキーを抜き出す Utility */ export type MethodKeys < T > = keyof { [ K in keyof T as T [ K ] extends Function ? K : never ] : T [ K ] } /** * @desc TypeOrm の Entity から別テーブルへのリレーションのキーを抜き出す Utility */ type RelationKeys < T > = Exclude <keyof T , PrimitiveKeys < T > | MethodKeys < T >> type OptionalKeys < T > = { [ P in keyof T ] -?: {} extends Pick < T , P > ? P : never }[ keyof T ] export type StrictEntity < T , JoinedRelationKeys extends RelationKeys < T > = never , RelationsOptionalKeys extends keyof T = Extract < JoinedRelationKeys , OptionalKeys < T > >, RelationsRequiredKeys extends keyof T = Exclude < JoinedRelationKeys , RelationsOptionalKeys > > = Pick < T , PrimitiveKeys < T >> & { [ K in RelationsOptionalKeys ] ?: T [ K ] extends undefined | infer I ? I extends Array < infer Item > ? ReadonlyArray < StrictEntity < Item >> : StrictEntity < I > : never } & { [ K in RelationsRequiredKeys ] : T [ K ] extends Array < infer Item > ? ReadonlyArray < StrictEntity < Item >> : StrictEntity < T [ K ] > } 読んでもらうより実際に例を見せるのが良いと思うので、まずはシンプルな例を提示します。 const question: StrictEntity < Question > = await repository .createQueryBuilder ( "question" ) .where ( "question.id = :id" , { id: 1 } ) .getOneOrFail () このように変数の型を明示的に StrictEntity<Question> で型付けをします。ここで StrictEntity<Question> は Question 型の部分型(より制約が強い型)なので型注釈のみで変数に入れられます。 StrictEntity<Question> は外部リレーションのプロパティを取り除くので、実態としては { id: number title: string text: string // categories: Category[] // 除外される } のような型として型付けされます。 また、リレーションが存在するときは const question: StrictEntity < Question , "categories" > = await repository .createQueryBuilder ( "question" ) .innerJoinAndSelect ( "question.categories" , "categories" ) .where ( "question.id = :id" , { id: 1 } ) .getOneOrFail () のように型付けをします。 型引数で categories を指定すると、今度は interface { id: number title: string text: string categories: StrictEntity < Category > [] } こういう型に型付けされます。 StrictEntity<Question, "categories"> で型付けされているときに innerJoinAndSelect が呼ばれていることを保証することはできませんが、型安全でない範囲をこの部分だけに閉じることができるようになりました。これであれば目視での確認もしやすいですし、 categories のリレーションのみ存在するという暗黙的な情報を型で表現できるようになりました。 データフェッチした変数がこれで型安全にできたので、サービスメソッドの引数を同じユーティリティを使った型安全にしていきます。問題点のところで紹介したメソッドの型付けを単に Question から必要なリレーションに限定した StrictEntity<Question, "categories"> へ変更します。 const someMethod = ( question: StrictEntity < Question , "categories" >) => { // categories を使ってゴニョゴニョする const x = question.categories.filter ( ... ) } これにより declare const question: StrictEntity < Question > someMethod ( question ) // categories リレーションが必要なためちゃんと型エラーになる Join されている情報が型で表現できるようになったため、必要なリレーションが足りない場合には型エラーが出てくれるようになりました。 これで、「Join せずに拾ってきたデータを誤って Join 前提の引数に渡してしまう」ようなミスを型レベルで防げるようになり、暗黙的にどのリレーションが Join されているかを意識する必要性がなくなりました。 まとめ TypeORM における Eager / Lazy Relation を使わないときの型安全性のなさを型ユーテリティを使って回避する方法を紹介しました。 このやり方でもデータフェッチ時の型付けと Join の有無の整合性は開発者で担保する必要があり完全な解決策にはなりませんが、比較的手軽につらさを軽減できると思います。TypeORM を使っていてリレーションの型安全性にお困りの方はぜひお試しください!
アバター
駅メモ!開発基盤チームの id:xztaityozx です!今回は CI/CD のお話です。 現在、駅メモ!チームでは Jenkins を使った CI/CD が構築されています。今回ここに GitHub Actions を加えることとなりました。チームでは段階的に GitHub Actions に移行していく計画です。 GitHub Actions を採用した理由としては、技術スタックの変化による需要の増加と Jenkins で抱えていた問題を解決するためという 2 点が主です。この記事では後者について書こうと思います。 現在の Jenkins 構成で困っていたこと 現在の Jenkins 構成では、起動できるサーバのスペックを柔軟に切り替えたり、追加できるようにしたいというリクエストに答えづらい状態でした。仕組み上の制約などから、追加の作業はそこそこ時間のかかる作業となっていたからです。 以前 CI でカバレッジ計測をしたいというリクエストがありましたが、提供まで 3 ヶ月の時間を要しました。 他のタスクに対応しつつだったので、かかりっきりというわけではなかったのですが、カバレッジ計測に合った別パイプラインの追加などが必要で、その作業すべてが手作業だったため、時間を取られてしまい提供が遅れてしまったのです。 このように、新しいワークフローを追加する際に、気軽さと柔軟性の欠如が問題となっていました。似たような事例でも直列に繋いだり、git hook 使用して対応したりといった対策が必要だった状況です。 もっと気軽に、自由に自動チェックやテストを追加したいですね…。 philips-labs/terraform-aws-github-runner の Multi runner モジュールを使っていろんな設定の Runner を使えるようにする この問題に対する解決策として、GitHub Actions と philips-labs/terraform-aws-github-runner の Multi runner モジュールを活用する方法を紹介します。 philips-labs/terraform-aws-github-runner はオートスケールする GitHub Actions の Self-Hosted Runner を AWS 上に構築してくれる Terraform モジュールです。 id:Eadaeda が以前にもこのテックブログで紹介していますので、興味があれば参照してみてください。 tech.mobilefactory.jp さて、この Multi runner モジュールの README には以下のように書かれています。 This module replaces the top-level module to make it easy to create with one deployment multiple type of runners. This module creates many runners with a single GitHub app. The module utilizes the internal modules and deploys parts of the stack for each runner defined. どうやらこのモジュール、1 つの GitHub App に様々な設定の Runner を複数個構築できるというもののようです。コミット履歴やプルリクエストを辿ってみると、実装のきっかけになった議論を見つけることができました。 github.com ざっくりとした内容は runs-on に与えた値で起動するインスタンスのインスタンスタイプや OS を制御できると良いよねということです。これの具体的な実現方法としては、単純に Runner の仕組みを複数個作るというもので、図にすると以下のような感じでしょうか。 multi-runner Multi runner モジュールを使ってみる 早速試してみましょう。今回の場合は t3.small や t3a.small を起動できる small という Runner の設定がほしいというリクエストが来たとして、Multi runner モジュールで Runner を作ってみます! module "multi_runner" { source = "philips-labs/github-runner/aws//modules/multi-runner" # 実装当時のLatest version = "3.6.0" prefix = "hoge-multi-runner" aws_region = data.aws_region.current.name vpc_id = data.aws_vpc.vpc.id subnet_ids = data.aws_subnets.subnets.ids github_app = { id = "ここにAppID" key_base64 = "ここに秘密鍵" webhook_secret = random_id.random.hex } webhook_lambda_zip = "./download-lambda/webhook.zip" runners_lambda_zip = "./download-lambda/runners.zip" runner_binaries_syncer_lambda_zip = "./download-lambda/runner-binaries-syncer.zip" multi_runner_config = { "small" = { # t3.smallかt3a.smallなRunner matcherConfig = { # このRunnerを呼び出すのに使う runs-on の値 labelMatchers = [[ "self-hosted" , "linux" , "x64" , "ubuntu-20.04" , "small" ]] exactMatch = true } runner_extra_labels = "ubuntu-20.04,small" runner_name_prefix = "linux-x64-small_" # エファメラルランナーの場合は0でよい # https://github.com/philips-labs/terraform-aws-github-runner#ephemeral-runners delay_webhook_event = 0 runner_config = { runner_os = "linux" runner_architecture = "x64" # エファメラルランナー設定を有効にしてRunnerは1つのJobを実行したら終了するように enable_ephemeral_runners = true ami_filter = { name = [ "hoge-github-runner-*" ] state = [ "available" ] } ami_owners = [ data.aws_caller_identity.current.account_id ] # このRunner設定はt3.smallかt3a.smallを起動する! instance_types = [ "t3.small" , "t3a.small" ] # 起動速度を考えて事前ビルドしたAMIから上げることにしたので、Syncerでactions/runnerのバイナリをSyncする機能はOFFにした enable_runner_binaries_syncer = false } } } } あとは terraform plan の出力を読んで terraform apply 、GitHub App 側の設定を行って終了です。更に Runner の設定を増やしたい場合は、 "small" と同じように増やしていけば良いだけです!簡単! module "multi_runner" { source = "philips-labs/github-runner/aws//modules/multi-runner" version = "3.6.0" ... multi_runner_config = { "small" = { ... } "medium" = { ... } "large" = { ... } } ... } Perl::Critic を動かしてみる テストの一部として組み込まれている Perl::Critic による静的解析を GitHub Actions で動かしてみます。 small ぐらいのスペックがあれば十分なので、最初の移行としてぴったりな題材です。 # https://json.schemastore.org/github-workflow.json name : Perl Critic on : pull_request : types : [ opened, synchronize, reopened ] paths : - "**.pl" - "lib/**.pm" - "t/**.t" - "t/**.pm" - ".github/workflows/critic.yaml" concurrency : group : critic-${{github.ref}} cancel-in-progress : true jobs : critic : # 動かすRunnerの指定をする部分 runs-on : [ self-hosted, linux, x64, ubuntu-20.04, small ] steps : - uses : actions/checkout@v3 - name : Get delta id : changed-files run : | echo diffs="$(gh pr diff --name-only ${{ github.event.number }} | grep -Pe '^(.*\.(pl|pm)|t/.*\.t)$' | tr '\n' ' ' )" >> $GITHUB_OUTPUT env : # 認証に secrets.GITHUB_TOKEN が使えるのが本当に便利 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }} - name : Run Check if : ${{ steps.changed-files.outputs.diffs != '' }} run : | /usr/local/bin/perlcritic --profile config/dev/perlcriticrc \ ${{steps.changed-files.outputs.diffs}} Perl ファイルの差分だけを検査するような簡単なワークフローです。ポイントは runs-on に Runner の labelMatchers 設定の値が書かれているところです。 注意点として、 runs-on のラベルは部分一致するもののうちのどれかが選ばれるという GitHub の仕様があります。例えば以下のように複数の Runner の設定があるとき [self-hosted, linux, x64, ubuntu-20.04, small] [self-hosted, linux, x64, ubuntu-20.04, large] [self-hosted, linux, x64, ubuntu-20.04, xlarge] runs-on に [self-hosted, linux, x64, ubuntu-20.04] と設定するだけでは small , large , xlarge のどれにもマッチするため、どの Runner がジョブを拾うかはわからないのです。このことは Multi runner モジュールの README にも書いてあるので、詳しくはそちらをお読みください。 すこし runs-on を間違えただけで意図しないコスト増加がありえるので、ワークフローのコードレビューでは必ず見ておきたい項目になりそうです。 まとめ Self-Hosted Runner な GitHub Actions を構築することになった 起動できる Runner のスペックを柔軟に切り替えたり、追加できるようにしたかった philips-labs/terraform-aws-github-runner の Multi runner できそうだったのでやってみた うまく動いたのでよかった 今後、利用が活発になると見える問題点や、 JIT Runner も試してみたいなと思っているので、また知見が溜まったらこのブログで記事を書きたいと思います。以上です。
アバター
こんにちは、エンジニアの id:mp0liiu です。 今年も7/2にPerlの最新安定バージョンである5.38がリリースされたので新機能や変更点についてまとめます。 5.38 はかなり変更点が多いですが、ニッチな機能に対する変更も多いので影響の大きそうな箇所だけ知りたい方は最初の方だけ読んで頂くといいと思います。 重要な変更点 class構文の追加 実験的機能としてですが、ついに Perl にclass構文が追加されました。 次のような構文になります。 use v5.38 ; use experimental 'class' ; class Point; field $x :param = 0 ; field $y :param = 0 ; method move( $dx = 0 , $dy = 0 ) { $x = $dx ; $y = $dy ; } method print { say "x: $x , y: $y " ; } my $p = Point->new( x => 3 , y => 5 ); $p->print (); # x: 3, y: 5 $p->move ( 2 , 4 ); $p->print (); # x: 2, y: 4 クラスは class 文で宣言します。使い方は package と同じ感じで、ブロックでスコープを作ったりバージョンを指定することもできます。 class 文を利用するとコンストラクタである new メソッドは自動的に生成されますが、自前で書くことはできないです(実行時にエラーになる) インスタンス変数は field 文で宣言します。スカラだけでなく配列、ハッシュの変数も宣言可能です。従来よく使われていたハッシュリファレンスを bless したクラスと違って、宣言されたインスタンス変数はパッケージ外部から直接参照することはできません メソッドは method 文で宣言します。 method 内では field で宣言されたインスタンス変数を参照することと、現在のオブジェクト自身を指す暗黙的変数 $self を参照することができます。他はサブルーチンと同じような仕様でシグネチャの定義や匿名メソッドを作ることも可能です。ちなみに $self からインスタンス変数を参照することはできません(Java の this.x のようにインスタンス変数を参照することは不可能) class, field には attribute を設定することが可能で、これにより上記の構文だけでは実現できない機能を実現しています。 上記で既に使用していますが、 field に :param attribute を設定するとその変数名の名前付き引数をコンストラクタに渡して初期化できるようになり、 field の初期値が定義されていない場合は名前付き引数が渡されないとエラーが発生するようになります。 継承は class に :isa attribute を設定することでできます。 use v5.38 ; use experimental 'class' ; class People { field $name :param; } class User :isa(People) { field $id :param; } 親クラスは1つしか指定することができません。また、親クラスのインスタンス変数は直接参照できません。 従来のクラスと違う点をまとめると以下のようになります。 継承できるクラスは1つだけ インスタンス変数はパッケージ外部から直接参照できない bless しているリファレンスがないので従来のオブジェクトとは異なる判定をされることがあるので注意 Scalar::Util::reftype や builtin::reftype では OBJECT という値が返ってきます Scalar::Util::blessed や bultin::blessed は同じように使えます 現在実装されている機能は主に上記のものだけです。 Moose などであったRole機能がなかったり、コンストラクタに渡された引数を操作できなかったり、アクセサの自動生成ができないため、まだ本格的に class 構文をプロダクトで利用するのは難しそうです。 一応今後の開発方針としては Role機能の実装 コンストラクタが呼び出されたあとに呼び出される ADJUST ブロックでコンストラクタに渡された引数を受け取れるようにする アクセサを自動生成する attribute の実装 メタプログラミングAPIの実装 コアモジュールをclass構文に対応させる ということが決まっているようなので、次以降のバージョンに期待ですね。 モジュールの最後で 1; を書かなくて良くなった モジュールの最後で真値を返さなくても良くなる機能 module_true が追加されました。 これを使うことでモジュールファイルの最後で 1; を書く必要がなくなります。 # Hoge.pm package Hoge ; use feature 'module_true' ; sub do_something {} # main.pl use Hoge; # 1; を書いていなくてもロードに成功する module_true は use v5.38 することでも有効になるので、積極的に use v5.38 していきましょう! # Hoge.pm package Hoge ; use v5.38 ; sub do_something {} # main.pl use Hoge; 設定ファイルなどで稀に任意の値をファイルの最後で返したいこともあると思いますが、そのような場合は use v5.38 しないか no feature 'module_true' などとすると従来と同じように利用できます。 構文エラー発生後パースを続けないようになった perl5.36以前では一度構文エラーが発生した後もパースを続けていましたが、perl5.38からは変数名や定数名を間違えたときのエラーを除いて一度構文エラーするとそこでパースが止まるようになりました。 例えば次のような構文エラーになるコードをperl5.36で実行すると use v5.36 ; my $hash = +{ a => 10 ; my $str = 'aaaaa' ; my $str2 'bbbbb' ; 次のようなエラーが発生します。 String found where operator expected at compile_error.pl line 7, near "$str2 'bbbbb'" (Missing operator before 'bbbbb'?) syntax error at compile_error.pl line 5, near "my " Global symbol "$str" requires explicit package name (did you forget to declare "my $str"?) at compile_error.pl line 5. syntax error at compile_error.pl line 7, near "$str2 'bbbbb'" Missing right curly or square bracket at compile_error.pl line 7, at end of line Execution of compile_error.pl aborted due to compilation errors. エラーが5行目と7行目で発生していて、無理にパースを続けているせいで Global symbol "$str" requires explicit package name など的外れなエラーメッセージもでてしまっていてわかりにくいです。 このコードをperl5.38で実行すると次のように最初の構文エラーしか発生しないようになっていて、構文エラー発生後パースを続けないようになったことがわかります。 syntax error at compile_error.pl line 5, near "my " Execution of compile_error.pl aborted due to compilation errors. 変数名や定数名などを間違えたときのエラーは変わらず複数あっても出続けるので、そのような修正は今までと同じようにできます。 use v5.38 ; my $str = 'Hello' ; say $srt ; my $str2 = 'World' ; say $srt2 ; Global symbol "$srt" requires explicit package name (did you forget to declare "my $srt"?) at compile_error_2.pl line 4. Global symbol "$srt2" requires explicit package name (did you forget to declare "my $srt2"?) at compile_error_2.pl line 7. Execution of compile_error_2.pl aborted due to compilation errors. 他の言語も基本的に複数箇所でコンパイルエラーが起きるコードを実行しても最初のエラーで止まるので、今までのperlが特殊だった感じがありますし、 途中でコンパイルエラーになるコードをパースし続けても変なパースをしてわかりにくいエラーメッセージが出たりセグフォになる可能性もあるので、いい変更だと思います。 廃止予定になった機能 次の機能が廃止予定になり、利用していると警告が発生するようになりました。 パッケージセパレータ ' パッケージの区切り文字は通常 :: を使いますが、Perl4の頃は ' を区切り文字に利用していてその流れでPerl5でもパッケージセパレータとして利用することができていました。 これがPerl5.42で廃止されます。 jcode.pl を利用しているCGIスクリプトなど、Perl4時代から運用しているコードがあれば将来かなり影響を受けることになりそうです。 switch構文、スマートマッチング演算子 Perl5.10で追加され、Perl5.18で実験的機能となったswitch構文とスマートマッチング演算子が失敗した機能とされ、Perl5.42で廃止されることが決まりました。 swtich構文は以下のような given, when などからなるswitch文のような構文です。 use feature 'switch' ; given ( $num ) { when ( $num < 50 ) { say 'yes' } default { say 'no' } } スマートマッチング演算子は以下のように項のデータ型に基づいて比較を行う演算子で、例えば配列やハッシュが一致するかを調べたりすることができました。 my @ary = ( 0 .. 10 ); my @ary2 = ( 0 .. 10 ); if ( @ary ~~ @ary2 ) { say 'Same array' ; } 廃止予定機能使用時の警告にサブカテゴリが追加 廃止予定の機能を使っている警告は no warnings 'deprecated'; で抑制することができます。 しかしこれだと廃止予定の機能を使用したの際の警告をすべて抑制してしまうので、 no warnings 'deprecated::${機能名}'; というように機能ごとに警告を抑制することができるようになりました。 例えばこのようなコードだとスマートマッチング演算子以外の廃止予定機能を使用したときも警告がでなくなります。 use v5.38 ; no warnings 'deprecated' ; use v5.10 ; if ( [ 1 .. 3 ] ~~ [ 1 .. 3 ] ) { print "match" ; } スマートマッチング演算子利用のサブカテゴリの警告のみを抑制よるすることでスマートマッチング演算子だけ使用したときの警告がでなくなります。 use v5.38 ; no warnings 'deprecated::smartmatch' ; # Downgrading a use VERSION declaration to below v5.11 is deprecated, and will become fatal in Perl 5.40 use v5.10 ; if ( [ 1 .. 3 ] ~~ [ 1 .. 3 ] ) { print "match" ; } 細かな改善やニッチな変更点 try-catch 構文、defer構文の改善 finally, deferブロック内で goto 文が使えませんでしたが、finally, defer ブロック内にジャンプする場合は使えるようになりました finally, defer から離れる制御フロー(return, gotoなど)はコンパイル時にエラーになるようになりました use v5.38 ; use experimental 'try' ; try { die if rand ( 1 ) > 0.5 ; } catch ( $e ) { say "caught error: $e " ; } finally { say "finally" ; return ; # Error: Can't "return" out of a "finally" block }; %{^HOOK} API の導入 perlのコア関数の中にはオーバーライドすることが難しい関数があるため、そのような関数の前後に呼ばれるコールバック関数を登録することのできるAPIが追加されました。 現状 require のみ対応しており、今後他のオーバーライドすることが難しい関数にもフック関数を登録できるようにする予定らしいです。 ${ ^HOOK }{ require__before } = sub { my ( $filename ) = shift ; warn "require before: $filename " ; }; ${ ^HOOK }{ require__after } = sub { my ( $filename ) = shift ; warn "require after: $filename " ; }; use v5.38 ; require List::Util; say List ::Util::sum( 1 .. 10 ); require before: List/Util.pm at override_require_hook.pl line 3. require before: strict.pm at override_require_hook.pl line 3. ... require after: strict.pm at override_require_hook.pl line 8. require after: List/Util.pm at override_require_hook.pl line 8. 55 コア関数をオーバーライドしたい場合は CORE::GLOBAL をコンパイル時にオーバーライドするのですが、それがAPIを利用してできるようになった感じだと思います。 use v5.38 ; BEGIN { *CORE::GLOBAL:: require = sub { my $file = shift ; warn "require args: $file " ; CORE:: require ( $file ); }; } use List::Util qw( sum ) ; say sum 1 .. 10 ; perl5380deltaでは require をオーバーライドするとスタックの深さが変わってモジュールの関数をエクスポートする処理で意図していないpackageにインポートしてしまう問題があると書いてあり、 実際に再現しようと上記のようなコードを用意してみましたが関数をエクスポートする処理(List::Util::sum)はちゃんと成功して再現できませんでした。 詳しい方がいらっしゃればぜひ教えていただきたいです。 @INCフックの改良 Perl で use, require などでモジュールをロードするとき、配列 @INC に格納されているディレクトリのリストの中にモジュール名に対応するファイルパスがないか検索するといった処理が実行されます。 @INC にはコードリファレンスやオブジェクトなどを入れることでモジュールのロード処理にフック処理を入れることができ、このフック処理を@INCフックと呼びます。 @INCフックにはフックが自身が @INC を修正するとセグフォや例外が発生する不具合があり、それが発生しないように修正されました。 また、次の機能が追加されました。 INCDIRメソッドの追加 新しいフックメソッド INCDIR が追加されました。 INCDIR メソッドが実装されたクラスのオブジェクトを @INC に追加してそのフックが実行されると、 INCDIR メソッドが実行され返り値のリストが @INC に追加されます。 package INCHooker { use v5.38 ; sub new ($ class, %args ) { return bless +{ %args }, $class ; } sub INCDIR { return ( '/usr/local/lib/perl5' , 'tmp' ); } } use v5.38 ; my $hooker = INCHooker->new; push @INC , $hooker ; # エラーになるが、エラーメッセージを見ると # Can't locate Ghost.pm in @INC (... INCHooker=HASH(0x55ecde25c5e8) /usr/local/lib/perl5 tmp) # というように @INC に INCDIR の返り値が追加されたことがわかる require Ghost $INC による@INCのイテレーション制御 @INCフックの中ではインデックスが $INC に格納されるようになり、 $INC を書き換えると次にチェックされる@INCの要素は $INC の値の次の要素(undefの場合は0)になります。 例えば次のようなフックがある場合、requireで存在しないモジュールをロードしようとしたとき、@INCの要素を走査していって最後にフックが実行されるがフックの中で $INC がリセットされ、また先頭から@INCを走査する・・・といった処理を無限に繰り返すようになります。 push @INC , sub { warn $INC ; undef $INC ; }; require Ghost; @INCフックは非常にトリッキーな機能なのでプロダクトの開発で利用することはないと思いますが、Carmel などのライブラリで利用されています。 モジュールマネージャーなど特殊なモジュールロードをするようなコードを書いている人にとってはより効率的にモジュールをロードできるようになったりと、これらの機能強化は嬉しい変更になるのかもしれません。 サブルーチンシグネチャのデフォルト式の定義性論理和と論理和 サブルーチンシグネチャのデフォルト引数を //= , ||= でも指定することが可能になりました。 前者は引数が未定義値なら、後者は偽値ならデフォルト値が使われます。 use v5.38 ; sub func ($ str //= 'World' ) { say "Hello, $str " ; } func(); # Hello, World func( undef ); # Hello, World func( 'Anonymous' ); # Hello, Anonymous 正規表現のパターン中のコードの埋め込みで楽観的評価が可能に 正規表現のパターンの中では (?{ code }) や (??{ code }) といった拡張構文でコードを埋め込むことが可能ですが、これらを使うとそのパターン全体での様々な最適化が無効になってしまいます。 そこで正規表現エンジンの最適化が無効化されない新しい拡張構文 (*{ ... }) が追加されました。 拡張構文中のコードが呼び出される回数が増えたり減ったりするかもしれないので挙動が不安定になる恐れがありますが、実行される回数は正規表現エンジンが動作する回数と一致します。 例えば通常の使用では O(N) のパターンが、 (?{ ... }) パターンを含むと O(N*N) になるところを (*{ ... }) に切り替えるとパターンは O(N) のままになるケースがあります。 my $count1 = 0 ; ( 'a' x 10 ) =~ / (.*)(? { $count1 ++ } )[bc] / ; say $count1 ; # 66 my $count2 = 0 ; ( 'a' x 10 ) =~ / (.*)(* { $count2 ++ } )[bc] / ; say $count2 ; # 11 コードの評価結果が正規表現として扱われる (??{ code }) に対応する (**{ code }) はまだ実装されていないです。 新しい正規表現変数 ${^LAST_SUCCESSFUL_PATTERN} の追加 現在のスコープで最後にマッチングに成功したパターンを利用したい場合は空パターンにすることで参照できていましたが、変数 ${^LAST_SUCCESSFUL_PATTERN} でも参照できるようになりました。 例えば最後にマッチングに成功したパターンにマッチする箇所を置換したい場合は置換対象のパターンを空にしていたところ、 my $str = 'foofoo' ; if ( $str =~ / foo / || $str =~ / bar / ) { $str =~ s// hoge / ; } say $str ; # hogefoo ${^LAST_SUCCESSFUL_PATTERN} を使うと次のように書き直すことができるようになり、コードが読みやすくなります。 my $str = 'foofoo' ; if ( $str =~ / foo / || $str =~ / bar / ) { $str =~ s/ ${ ^LAST_SUCCESSFUL_PATTERN } / hoge / ; } say $str ; # hogefoo 組み込み関数(builtin)の追加 export_lexically 関数の追加 現在コンパイル中のスコープにシンボルをエクスポートする関数です。 これによって builtin のレキシカルインポートの機能が builtin 以外のモジュールでも利用できるようになりました。 使い方は奇数番目の引数にシンボルの名前を指定して偶数番目の引数に対応するエクスポートしたい値のリファレンスを指定します。 値が変数の場合名前にシジルが必須で、変数の型と一致しなければなりません。値がサブルーチンの場合はなくてもよいです。 この関数はコンパイル時に呼び出されなければなりません。 通常この関数はモジュールのimportメソッド内で使われ、use文によって呼び出されます。 次のコードは関数 do_something と 変数 $hoge をレキシカルスコープにエクスポートするモジュールのコードと、それを use したコードです。 Hoge.pm package Hoge ; use v5.38 ; use builtin qw( export_lexically ) ; no warnings 'experimental::builtin' ; sub import { my $class = shift ; export_lexically do_something => \ &do_something , '$hoge' => \ 'hogehoge' ; } sub do_something { say 'Who am I?' ; } main.pl use v5.38 ; { use Hoge; do_something(); # Who am I? say $hoge ; # hogehoge } do_something(); # Undefined subroutine &main::do_something 2行目からのスコープ内のみに Hoge::do_something をエクスポートしていて、スコープから外れると未定義になることがわかります。 is_tainted 関数の追加 perlには汚染モードと呼ばれる外部から入力されたデータを汚染されたデータとしてチェックするモードがありますが(詳細は perlrun , perlsec を参照)、それが有効になっているときに値が外部から入力されたデータかどうかをチェックする関数です。 use v5.38 ; use builtin qw( is_tainted ) ; no warnings 'experimental::builtin' ; my $line = <STDIN> ; if ( ${ ^TAINT } ) { say is_tainted( $line ); # 1 } Scalar::Util の tainted を builtin に持ってきた形になります。 まとめ 一昨年までのPerl開発チームでの体制変更がいい影響を及ぼし続けているのか、今年もかなり大きな変更がありました。 特にクラス構文の追加やモジュール末尾の 1; が不要になったのは大きく、初心者が躓きやすいポイントや他言語出身の方が感じるとっつきづらさがどんどん減っていってると思います。 今後もPerlの進化が楽しみですね。 昨年度から perldoc.jp でperldocの翻訳がかなり進んでおり、この記事では書けなかったこともあるので詳しいことが気になった方は ドキュメント の方もぜひ読んで見てください。
アバター
こんにちは!BC チームでエンジニアをしている id:d-kimuson です。 最近、弊チームで構築した社内向け Web API のバックエンド設計をしたので事例として紹介しようと思います。 フレームワークとして NestJS を採用していますが、NestJS Way よりも TS Way を意識した設計をしており、このエントリの主題でもあるため、TS Backend の設計事例として読んでいただければと思います。 対象システムの概要 社内の他サービス向けの Web API で、他チームのサービスを経由してエンドユーザーに届く中間システム チーム内のサービスからもチーム外のサービスからも叩かれる想定 チーム外からも叩かれるため、なんらかのスキーマを共有したいというモチベーションがある → 2023 年現在で標準的な OpenAPI Specification (以後 OAS と呼びます) を共有したい その他の採用している技術や環境 Node.js v18 ORM として Prisma なぜ NestJS か? システムの概要で触れたように、本システムでは OpenAPI のスキーマを吐き出したいというモチベーションがありました。 TypeScript における Server/Client 間でのスキーマの共通化としては tRPC や frourio 等も選択肢としてあります。 しかし、今回のシステムでは、クライアント側は TypeScript とは限らないため、言語に依存しない標準的なスキーマとして書き出す必要があり、OAS を採用しました。 OAS スキーマを用意するときは、実装とスキーマが一致することを保証するため コードファースト: 実装を書くと実装に沿ったスキーマが生成される スキーマファースト: (OpenAPI の)スキーマを書くと実装のボイラープレート(あるいはそのまま使える実装)が生成できる。また実装に対する制約の型定義等が生成され、スキーマを満たす実装しかかけない状態になる の 2 つのいずれかの選択肢を取るのが望ましいと考えています。 今回は先にスキーマを用意したかったのでスキーマファーストのアプローチの方が望ましかったのですが、TS Backend においてスキーマファーストを適切に行うスタンダードな方法があまりなく、コードファーストなアプローチを採用しつつ、インタフェースだけ先にコーディングする形で「先にスキーマを用意したい」という要件を満たすことにしました。 コードファーストのやりやすさとして、 NestJS はコントローラーの引数・戻り値の型がそのまま OAS が書き出され、体験がとても良いことに加えて チームでの採用事例があったこと (Express 等の薄いフレームワークと比べ) NestJS では包括的に Web 開発における機能をフレームワークとして提供してくれるため、Web 開発全般で必要な標準的な機能を再実装することなくドメインの API 開発に集中できること 等の利点もあることから、フレームワークとして NestJS を採用しました。 基本的な方針 公式ドキュメント・エコシステムの型チェックオプションとは距離を置く NestJS 公式のジェネレータでボイラープレートを作成すると、TypeScript のデフォルトでは strict オプションによって off にされている any を許容するオプション null 安全性をなくするオプション が有効にされた状態の tsconfig.json が作成されます。 公式ドキュメントでも export class CreateCatDto { name: string age: number breed: string } のようなコンストラクタでの初期化がされていない DTO(Data Transfer Object) を標準的に紹介しており、strictNullChecks が off であることが前提となっていることがわかります。 可能な限りフレームワークの Way や公式ドキュメントの内容に沿っておくことはとても重要ですが、少なくとも NestJS 周辺の型チェック周りの行儀はそれほど良くないので、適切な距離感で付き合っていくことが大事だと思います。 関数型プログラミングを軸とする NestJS はオブジェクト志向をベースとした他言語でも適用できるフレームワーク・設計を TS の世界に持ってきたようなフレームワークです。 *1 標準でコントローラー層やサービス層、組み込みの DI 解決に class を使ったサンプルを提示していたり、実際に DI 機能を提供している点から素直に実装をするとオブジェクト志向的な設計・実装になる引力が働きがちだと思っています。 しかし、個人的に柔軟なデータ構造の取り回しがしやすい構造的部分型の型システムを持つ TypeScript においては、データ構造とふるまい(メソッド)をセットで定義するオブジェクト志向的なやり方よりも、型駆動でドメインのデータ構造を宣言し、ふるまいを関数として分離する関数型的なアプローチのほうが相性が良いと考えています。 したがって、今回の Web API 開発においては関数型プログラミングのエッセンスを軸に設計を行いました。 関数型プログラミングとは言っても、大事にしているのは いわゆる「Entity」に定義されるメソッドとデータ構造は、型と関数としてしっかり分離しよう *2 副作用を分離しよう 関数はユニットテストが書きやすい小さい単位で作っていき、それらを組み合わせることでビジネスロジックを構成しよう という側面に重きを置いています。 一方、TypeScript における関数型プログラミングとしては、 fp-ts 等のライブラリが提供する 関数のカリー化 ( (arg1, arg2) => ret を (arg1) => (arg2) => ret にする) 関数合成・パイプ ( f(g(arg)) ではなく pipe(arg, g, f) 的な書き方) 等の機能を使うこともできますが、こういった書き方・ライブラリとは距離を置いています。 これは 標準の TypeScript の書き心地とはかなりズレてしまうこと 追加の学習コストが発生すること 標準とのズレによるチームでのメンテナンス性の低下 というデメリットがあるためです。 関数型を軸としたバックエンド設計として、書籍 Domain Modeling Made Functional を参考にしています。知見が少ない関数型ベースの設計手法が語られていてとても参考になりました。 基本的なコンセプトは以上の 2 点です。 ここからはより具体的なアーキテクチャや方針について紹介していきます。 型チェックを厳格にする 「公式ドキュメント・エコシステムの型チェックオプションとは距離を置く」でも紹介したように、公式がデフォルトで提供する型チェックはかなり緩い状態になっています。 今回は、 tsconfig/bases の strictest オプションをベースにしつつ、NestJS で利用できるようにオプションを一部上書き指定しています。 以下は実際に利用している TS 設定の一部です。 { " extends ": [ " @tsconfig/strictest ", " @tsconfig/node18 " ] , " compilerOptions ": { " module ": " commonjs ", " declaration ": true , " removeComments ": true , " emitDecoratorMetadata ": true , " experimentalDecorators ": true , " allowSyntheticDefaultImports ": true , " sourceMap ": true , " outDir ": " ./dist ", " incremental ": true , " skipLibCheck ": true , " importsNotUsedAsValues ": " remove " // decorator で使う・eslint でやるので } } 特に後から緩い型チェックを硬い型チェックへの移行していくのは大変になりますので、硬すぎるくらいのチェックを適用して運用に併せて緩くしていくくらいのスタンスをオススメします。 また、この記事では特に断りがない限り以上の型チェックオプションが適用された TS 5.0 を利用していることを前提とします。 strictNullChecks と DTO と constructor NestJS ではリクエストパラメタ/ボディ、レスポンスボディの型定義には DTO を使います。 DTO とはデザインパターンの一種で、一般的にビジネスロジック(メソッド)が含まれないデータをいれる箱のことを指します。厳密な定義は置いておいて、NestJS の文脈においては コントローラーの引数(リクエストパラメタ・ボディ)、戻り値(レスポンスボディ)のインタフェースを宣言する class であり 引数に関して class-validator のデコレータを書くと勝手にバリデーションをしてくれてれる class であり *3 @nestjs/swagger によって、引数・戻り値をプロパティの型定義から OAS に書き出してくれる class といった意味合いを持っていて、インタフェース宣言に加えてバリデーションやOAS書き出しの責務を持つ特殊な class だと思ってもらえれば良いです。 strictNullChecks オプションが有効な状態では公式ドキュメントに提示されている DTO のサンプル export class CreateCatDto { name: string age: number breed: string } を使うと、constructor でプロパティを初期化していないため型エラーが発生します。 constructor をちゃんと書いてあげるのが理想的ですが、 class-validator のデコレータと Parameter Properties の併用ができないので export class CreateCatDto { @IsString () name: string @IsNumber () age: number @IsString () breed: string public constructor( name: string , age: number , breed: string ) { this .name = name this .age = age this .breed = breed } } のような非常に冗長な記述になってしまいます。 弊チームでは Non-Null Assertion Operator を使って class CreateCatDto { name ! : string age ! : number breed ! : string } のようにして型エラーを回避することにしています。 また、チームでは Dto の使い方に関して、2 つの規約を敷いています。 ① Dto は abstract class で宣言すること ② Dto を controller, dto 以外のファイルから参照しないこと ① を敷いているのは コントローラーの戻り値は Dto のクラスインスタンスではなくプレーンオブジェクトを返すように統一する ことが目的です。 TypeScript では、Dto 型のクラスの戻り値が指定されているときに、実際にはクラスインスタンスではなくプレーンオブジェクトを返しても型エラーにならないという挙動になります。 import { plainToClass } from "class-transformer" class SomeController { // class が private なフィールドを持たないときに継承関係ではなくプロパティの構造で型チェックされる仕様を利用してプレーンオブジェクトを返すパターン createCatV1 () : CreateCatDto { return { name: "nyash" , age: 3 , breed: "A" , } } // CreateCatDto のインスタンスを作成してインスタンスか返すが、プロパティは Object.assign で割り当てるパターン createCatV2 () { return Object .assign (new CreateCatDto (), { name: "nyash" , age: 3 , breed: "A" , } ) } // CreateCatDto のインスタンスを作成してインスタンスか返すが、プロパティは plainToClass で割り当てるパターン createCatV3 () { return plainToClass (new CreateCatDto (), { name: "nyash" , age: 3 , breed: "A" , } ) } } 上記の createCatV1 ではプレーンオブジェクトを返し、 createCatV2 , createCatV3 ではクラスインスタンスを返していますがどちらも型チェックを通る正しいコードです。 これらがいずれも許容される混在する状態では テストで toBeInstanceOf で判定できなかったり コーディングする上でインスタンスなのか、プレーンオブジェクトなのかを意識する必要があり、認知負荷が増える といった辛さが想定されるので、「プレーンオブジェクトを返すこと」に一貫性をもたせるために ① Dto は abstract class で宣言すること の規約を敷いています。 ② Dto を controller, dto 以外のファイルから参照しないこと に関しては、Dto はあくまで「OAS を書き出したり class-validator を使えたりするために、インタフェースが欲しいだけだけどやむなく class を使う必要がある」というだけなので、必然性のあるコントローラー層以外からは使うのはやめましょうね、というものです。 DTO の class との向き合い方は今回の方針以外にもいくつか考えられる *4 と思いますが、いずれにせよ型チェック上の抜け穴になりやすいのでコーディング規約やガイドライン等でスタンスを明確にしておくことが望ましいと思います。 ドメインモデルは class ではなく単一ファイル内に宣言する type と関数によって宣言される よく class で表現される Entity は、一般的にデータと関連するメソッド群を持ちます。 class UserEntity { public constructor( public id: number , public firstName: string , public lastName: string , public birthDate: Date , public isAuthenticated: boolean , public createdAt: Date ) {} public get fullName () : string { return this .firstName + " " + this .lastName } } 以上のような Entity は型と関数に分離して以下のように宣言します。 /* Orm からマッピングされた型 */ type User = { id: number firstName: string lastName: string birthDate: Date isAuthenticated: boolean createdAt: Date } /** * すべてではありませんが、DB のレコードとドメインモデルが対応関係になることも多いと思います * そういうケースでは以下のような形を取っておくと便利です * * @example UserEntity<{ posts: Post[] }> -- リレーションを持つデータ構造のパターンだったり * @example UserEntity<{ tag: 'Validated' }> -- 同じデータ構造でバリデーション済み等のライフサイクルを表すタグを付加したり */ export type UserEntity < AdditionalFields extends Record < string , unknown > = {} > = User & { age: number } & Omit < AdditionalFields , keyof User >; export const buildUserEntity = < T extends User >( data: T ) : UserEntity < T > => { return { ...data , age: age ( data.birthDate ), } satisfies UserEntity as UserEntity < T > } ; /** * @desc * - メソッドに該当する処理は `Pick<EntityType, 依存するプロパティの一覧>` を第一引数に取る関数 * - こうすることで必要なプロパティが明示されるのと、部分型(例: `Omit<UserEntity, 'age'>`)に対しても最低限のプロパティが揃っていれば呼べるようになる */ const fullName = ( user: Pick < UserEntity , 'firstName' | 'lastName' >) : string => user.firstName + ' ' + user.lastName 型と関数が分離されているので、ロジックを別ファイルに持っていくこともできますが、ドメインモデルと同じファイル内にかかれているほうが凝集で、読みやすいので同ファイル内に置いています。 データ型と関数が分離されていることで、class では冗長な記述が必要だった型を使った制約の表現がしやすくなります。 例えば「バリデーション通過済みのデータでのみユーザー作成のロジックを実行できる」を以下のように型で表現・制約をつけることができます。 type ValidatedUserEntity = UserEntity < { tag: "VALIDATED" } > // service const validateUser = ( user: UserEntity ) : ValidatedUserEntity => { // バリデーションを通過したデータにのみタグ 'VALIDATED' を付加する return { tag: "VALIDATED" , ...user , } } const createUser = async ( validatedUser: ValidatedUserEntity ) => { // ... } declare const user: User const userEntity = buildUserEntity ( user ) // validateUser を通過していないデータでは呼び出せない createUser ( userEntity ) // Argument of type 'UserEntity<User>' is not assignable to parameter of type 'ValidatedUserEntity'. const validatedUser = validateUser ( userEntity ) createUser ( validatedUser ) // 型エラーなしで呼び出せる 他にも例えばバリデーションを通過して認証済みに絞られているデータであれば type AuthenticatedUser = UserEntity & { isAuthenticated: true } のように型を作って制約として利用することもできます。 このように同じ User ドメインモデルでも一連の手続きのタイミング毎で取りうるデータ構造は代わります。 それぞれのステップを区別した・厳格な型で表現できると、暗黙的にではなく型チェックで呼び出しの制約等を制御することができます。 class ではこの辺の取り回しがし辛いですが、型と関数に分離してあげることで柔軟に・型駆動でビジネスロジックを記述していきやすくなります。 メソッドに対応する関数も Pick<UserEntity, 'firstName' | 'lastName'> のように必要なプロパティだけ列挙することで、より制約の強い部分型(例えば AuthenticatedUser )でも最低限のプロパティだけ持っていれば呼び出すことができます。 副作用を分離する 関数型プログラミングで大事にされるエッセンスの 1 つとして副作用の分離があります。 副作用とはすなわち「関数の戻り値を返す以外の効果」のことで、わかりやすいところで HTTP 通信・DB の Read/Write・ロギング等があります。 副作用の特徴として 外部状態に依存するため、処理が安定しづらい 例えば正しく実装された関数が依存する DB や HTTP 通信先の状態に影響されて壊れたりする可能性がある 外部の状態に依存するためテスタビリティが低い 事前に依存状態を用意する必要があり、テストケースが関数の入力 → 出力ではなく、事前の DB 状態 → 関数の出力(あるいは事後の DB 状態)になり、テストケースが読みづらい傾向にある 上手く DB がセットアップされない・テストケース単位での DB 分離が壊れた等、対象のテストケースとは直接関係ないレイヤーの問題でテストがフレイキーになりやすい (DB の副作用について) 副作用がボトルネックになり、テストの実行時間が長くなる DB 接続がボトルネックになる 融和策としてインメモリ DB への差し替え等のアプローチもあるが、根本的な解決にはならず、また本番と異なる環境でテストを回すことになってしまう 依存する DB の状態がテストケースごとに分離されている必要があるため並列でのテスト実行がしづらくなる というつらい側面があります。 今回のアプリケーションでは全体のアーキテクチャはレイヤー構造を取っていて、レイヤーレベルで副作用を許容する層としない層を分離することで、つらい範囲を狭めています。 ただし、副作用の分離と言ってますが純粋関数型の言語でない TypeScript では厳密にすべての副作用を分離することは難しくコストも見合わないので考えておらず、 特に影響の大きい「外部との HTTP 通信」や 「DB 接続」を副作用と考えて 分離しています。 特に補足がない限り、この記事内で副作用と読んだときはこれらを指すものとします。 *5 レイヤー構造と副作用 アプリケーションで採用しているレイヤー構造は以下の通りです 層 説明 副作用 domain-object 上のセクションで説明したドメインモデル(型とモデルに閉じた関数)が入る層(関心がドメインモデル) なし サービス層 複数のドメインモデルに跨るビジネスロジックを実装する層(関心がシステム) なし リポジトリ層 HTTP 通信や DB 依存があるときにデータフェッチを行う層 あり ユースケース層 サービス層・リポジトリ層・domain-object のロジックを組み合わせて意味のあるユースケースを実装する層です層 なし コントローラー層 HTTP リクエストを受けてレスポンスを組み建てる層です あり 依存関係は以下のようになります。 ユースケース層は厳密には副作用を持つ場合が多いですが、後述する Dependency Injection を使うことで、見かけ上副作用がない状態 *6 にしています。 jest.config レベルでテストを 2 種類の系統に分離する 副作用を分離したい 1 つの大きな理由として「テスタビリティを高めたい」という点がありました。 そこで、層ごとに副作用を扱うルールが分離されていることから jest.config レベルで副作用を持つ層のテストを副作用を持たない層のテストを分離しています。 以下は実際に使っている jest.config の抜粋です。 // jest.config.pure.ts import type { Config } from "jest" ; import { baseConfig } from "./jest.config.base" ; const config = { ...baseConfig , testMatch: [ "**/usecases/**/*.spec.ts" , "**/services/**/*.spec.ts" , "**/domain-object/**/*.spec.ts" ] } satisfies Config ; export default config ; // jest.config.with-side-effects.ts import type { Config } from "jest" ; import { baseConfig } from "./jest.config.base" ; const config = { ...baseConfig , testMatch: [ "**/*.controller.spec.ts" , "**/repositories/**/*.spec.ts" ] , setupFilesAfterEnv: [ ...baseConfig.setupFilesAfterEnv , // DB 依存側はセットアップや DBリセット等の setup が必要になる "./src/test-utils/setup/separate-db-per-worker.ts" , "./src/test-utils/setup/setup-prisma.ts" , "./src/test-utils/setup/reset-table.ts" , ] } satisfies Config ; export default config ; あとは { " scripts ": { " test:pure ": " jest --config ./jest.config.pure.ts ", " test:with-side-effects ": " jest --config ./jest.config.with-side-effects.ts " } } のような npm-scripts を用意しておくことで $ pnpm test:pure # DB 非依存のテストのみ実行される $ pnpm test:with-side-effects # DB 依存のテストのみ実行される のような形で分離して手元でテストを実行できます。 test:with-side-effects 側は ファイルごとに jest の worker 単位で並列化されていますが、ファイル単位では個別のテストケースが直列に動くこと 参考: Prisma で本物の DBMS を使って自動テストを書く - mizdra's blog テストケースのたびに DB のリセット処理が入ること の 2 点が理由で、仮に DB の副作用がなかったとしてもテストケースごとにオーバーヘッドが発生する構成になっています。 test:pure が test:with-side-effects から分離されていることで、 test:pure 側に不要なオーバーヘッドがかからず、軽量なテストとして手元で実行しやすくなり、TDD 的な開発がしやすくなると考えています。 test:pure 側ではインフラストラクチャへの関心がないビジネスロジック側に関心が置かれているため、このレイヤーを軽量に・高速にテストできるととても体験が良いです。 現時点での計測値としては、後者は 90 テストケースありフルテストで 82 秒程かかりますが、前者は 126 テストケースが 2.5 秒程で実行できます。 副作用のサンドイッチによる分離 基本的にサービス層・domain-object はあまり意識せずとも副作用のない関数になっていきやすいですが、ユースケース層は DB に依存することが多いので結構厳しいです。 副作用はできるだけ分離したいので のようにリクエストの前後に副作用を集約できるような形式を取れるのであればこれが理想的です。 ただし、複雑なユースケースになってくると、DB から取得した値を元に他の値を DB から値を取得する必要がある場合や、処理の途中で副作用を挟まらずを得ないケースもあります。こういうケースではユースケース層から副作用を排除することは難しいため、Dependency Injection を使っていきます。 関数ベースの Dependency Injection Dependency Injection と聞くとクラスベースの DI を思い浮かべる人が多いと思いますが、今回は高階関数を使った DI を使っていきます。 Injectable な関数を宣言するためのユーテリティ関数を用意します。 type ArgType < T extends Function > = T extends ( ...args: infer I ) => any ? I : never ; type InjectableFn = ( ...args: any ) => ( ...args: any ) => any ; export type IInjectable < Deps extends Record < string , ( ...args: any ) => any >, Args extends ReadonlyArray < unknown > = [] , Ret = void > = ( deps: Deps ) => ( ...args: Args ) => Ret ; export const defineInjectable = < DepFns extends Record < string , InjectableFn >, Deps extends Record < string , ( ...args: any ) => any > = { [ K in keyof DepFns ] : ReturnType < DepFns [ K ] >; } >( _depFns: DepFns ) => < Fn extends ( deps: Deps ) => ( ...args: any ) => any >( fn: Fn ) : Fn => fn satisfies IInjectable < Deps , ArgType < Fn >, ReturnType < Fn >>; 中身の説明は重要ではないので置いておきますが、高階関数を使って (ORM) => (引数) => Promise<取得した値> の形式で実装されたリポジトリ層の DI 解決をするユーティリティです。 以上のユーテリティを使って、ユースケース層は以下のように書きます。 // repository がこんな感じで定義されてるとして const findUserById = ( prisma: PrismaClient ) => async ( id: number ) => prisma.user.findUnique ( { where: { id , } , } ) // ユースケースはこう定義してあげます const loginUsecase = defineInjectable ( { findUserById /* 依存するリポジトリ層, (prisma) => (arg) => Promise<Data> */ , } )( ( deps /* DI が解決された (arg) => Promise<Data> を受け取る */ ) => async ( id: number ) => { const user = await deps.findUserById ( id ) // ... } ) コントローラー層からは、リポジトリ層に prismaClient (ORM Client) を Inject してあげてそのまま呼び出します。 const result1 = loginUsecase ( { findUserById: findUserById ( prismaClient ), } )( 1 ) テストからはリポジトリ層を実 DB 依存の元の関数からテスト用のものに差し替えてあげることで実 DB への依存をはがすことができます。 // テストでの呼び出し方 const result = loginUsecase ( { findUserById: async () => ( { id: 1 , firstName: "yamada" , lastName: "taro" , } ), } )( 1 ) 高階関数での DI を使うことで副作用がない(取り除かれた)状態を作ることができました。 提示したサンプルコードは こちら で試すことができます。 例外戦略 TypeScript における例外は組み込みの throw がありますが 関数のインタフェースからどんな例外を投げるかわからない エラーハンドリングが漏れる可能性がある というつらさがあり、代案として主に 2 種類の他のアプローチが提唱されています。 組み込みの例外を throw ではなく return すること サードパーティライブラリ等を使った Result 型を使うこと 正常系・異常系をラップした構造 result.isOk() ? result.value : result.err のようなインタフェースで正常系にアクセスさせる型 これらのアプローチではいずれも異常系が戻り値として関数のインタフェースに現れるので、関数を見れば異常系がわかること・異常系のハンドリングが漏れない点で throw のつらいポイントが解消されています。 throw は禁止すべきか? throw は型安全なエラーハンドリング手法ではないので、特にビジネスロジックの濃い層ではできるだけ型安全な手法を取りたいです。 一方、throw に優れている点がないかというとそんなことはなくて 「コードがシンプルになる」 というとても大きなメリットがあります。 return Error や Result のみ(throw 禁止)でコードをちょっと書いてみるとわかるんですが、これはこれでとてもつらいです。React や Vue で props のバケツリレーつらいよね、みたいな話がありますが同種の辛さがあり、依存する関数から関数へひたすら Error (あるいは Result ) をバケツリレーする必要が出てきます。 その点 throw だと一番下で投げた後、一番上のコントローラー層あるいはミドルウェアなりで catch して異常レスポンスを返せば良いので、ビジネスロジックを書いてる層のコードがとてもすっきりします。 このメリットは結構大きいと考えていて 型安全なエラーハンドリングをしたいケース(呼び出し元でハンドリングを期待するケース) そうでないケース をしっかり定義して使い分けていく、というスタンスを取っています。 Result 型は使わず return Error する Result 型は return Error と同じく インタフェースから想定される例外が読み取れる 利用側にハンドリングを強制させられる というメリットを提供しますが、例外システムに標準でないライブラリを利用して強く依存することになるので、標準の機能だけで実現できる return Error 形式を採用しています。関数型のエッセンスは使うが、fp-ts を使わない理由と同様です。 return Error パターンを使う場合の注意点として、private なプロパティを持たない class は継承関係ではなくデータ構造で型チェックされてしまうため型チェック上の抜け穴が発生してしまう問題があります( 参考 )。 これを回避するために const errorSymbol = Symbol () export abstract class BaseError extends Error { private readonly [ errorSymbol ] : undefined } のような Base となるエラーを用意しておき、これを継承する形を取るのがオススメです。 throw と return Error の棲み分け throw と return する例外の使わ分けについてですが、このプロジェクトでは例外を以下の 4 種類に分類しています。 SubNormalError 準正常系な例外で、例えば「リポジトリ層でレコードなかった」とか「Unique 制約にひっかかった」とか「バリデーション通過できなかった」等の 仕様として想定される例外 を指す AbnormalError 無条件に 500 を返して良いもの 発生条件など想定されてはいるが、アプリケーションとして異常な状態で DB 接続が確認できない、依存する外部 API が動作していない等、 発生条件は想定されているがアプリケーションとして異常な例外 を指す UnExpectedError その分岐を通ること自体が 開発時点で想定されない例外 を指す 網羅チェックや、型システム上は通る分岐だけど実際にはありえない場所等 HttpException 投げると指定のレスポンスを返せる例外 そして AbnormalError と UnExpectedError はハンドリングを強要させるメリットが少なく、例外のバケツリレーのデメリットだけ受ける形になってしまうので throw する SubNormalError については呼び出し元でハンドリングを強制させたいので return する HttpException は Usecase/Controller からのみインスタンス化及び throw できる という方針を取っています。 これで、型安全にハンドリングするメリットが薄い箇所のみ throw でコードベースをシンプルに保ちつつ、それ以外の場所で型安全に(ハンドリングを強要させた状態で)例外を扱うことができるようになりました。 その他 Tips 基本的なアーキテクチャ設計のコンセプトは以上になりますが、その他便利な Tips をいくつか紹介します。 eslint で参照ルールを設定する コーディングルールを定めたらなるべく eslint で弾けるようにルールを設定しています。コードレビューで指摘が入るより効率が良いことと、強制力がより強いためです。 eslint-import-plugin の no-restricted-paths を使うとレイヤー構造の参照ルールを宣言できます。 弊社では DTO を Controller/Dto ファイル以外で使わない Usecase から別の Usecase を呼ばない Service から Repository を呼ばない のルールを eslint で表現して設定しています。 .eslintrc.cjs { rules: { "import/no-restricted-paths" : [ "error" , { zones: [ { from: "./src/**/*.!(controller|dto).ts" , target: "**/*.dto.ts" , message: "Dto は Controller の引数・戻り値でのみ使用が許可されています" , } , { from: "./src/**/usecases/*.!(spec).ts" , target: "./src/modules/**/usecases/*.ts" , message: "Usecase 層から別の Usecase 層の参照は許可されていません" , } , { from: "./src/**/repositories/*.ts" , target: "**/features/**/services/**/*" , message: "Service 層から Repository 層への参照は許可されていません" , } , ] , } , ] , } } リテラル型とユニオン型はカスタム ApiDecorator を使う NestJS の OAS 書き出しの機能は便利ですが、制約がいくつかあります。 ① プロパティの型が type や interface だと書き出せない ② プロパティの型がユニオン型だと書き出せない ③ プロパティの型がリテラル型だと書き出せない ① についてはプロパティも Dto 使いましょうね、で良いんですが後者についてはデコレータを使って型定義を明示して上げる必要があります。 以下のようなユーテリティを用意しておくと便利です。 /** * @example ApiProperty({ * description: '説明', * ...enumSchema<Gender>(['male', 'female']) * }) */ export const enumSchema = < T extends string >( enums: readonly T [] , example?: T ) => { return { example: example ?? enums [ 0 ] , enum : enums as T [] , } satisfies SchemaObject ; } ; /** * @example ApiProperty({ * description: '説明', * ...unionSchema([SomeDto, Some2Dto], exampleData) * }) */ export const unionSchema = < TargetDtoClass , const T extends ReadonlyArray < AbstractClass < TargetDtoClass >>, U = T [ number ] >( unions: T , example: U extends { prototype: unknown } ? U [ "prototype" ] : never ) => { return { example , oneOf: unions.map (( union ) => ( { $ref: getSchemaPath ( union ), } )), } satisfies SchemaObject ; } ; /** * @example ApiProperty({ * description: '説明', * ...arraySchema(enumSchema(...args)) * }) */ export const arraySchema = ( itemSchema: SchemaObject ) => ( { type : "array" , items: itemSchema , } ); ユニオン型やリテラル型(+これらを使った配列型)の場合には type Gender = "male" | "female" class SomeDto { @ApiDecorator ( { ...enumSchema < Gender >( "male" ), } ) gender ! : Gender @ApiDecorator ( { ...unionSchema ( [ SomeDto , SomeDto2 ] , exampleData ), } ) someUnion ! : SomeDto | SomeDto2 @ApiDecorator ( { ...arraySchema ( enumSchema < Gender >( "male" )), } ) genders ! : Gender [] } のように宣言することで適切な OAS を吐き出すことができます。 実装後の課題感 まだ実装を進めているステータスですが、現時点での所感や振り返りをして行きます。 Usecase 呼び出しが Fat になりがちでつらい 主な副作用分離のパターンとして 副作用のサンドイッチ 副作用を DI するパターン を想定していましたが、副作用分離を意識しないときの書き方と近くて書きやすかったこともあり、1 より 2 のパターンでの実装が多くなりがちでした。 2 のパターンでは依存する対象が多いと DI の呼び出し部分が Fat になりやすく someUsecase ( { repository1 , repository2 , repository3 , // ... } )( arg ) のような書き方になります。 冗長ではありますが、やむをえない部分ではあるので許容しています。ただし、DI が必要ないケースでまで DI していないかは注意していきたいなと思います。 また、関数型DIのライブラリである velona を使うとプレーンな高階関数ではなく、必要な時だけ inject することができるインタフェースになっています。 // リポジトリのサンプルの抜粋です import { basicFn } from './' const injectedFn = basicFn.inject ( { add: ( a , b ) => a * b } ) expect ( injectedFn ( 2 , 3 , 4 )) .toBe ( 2 * 3 * 4 ) // pass expect ( basicFn ( 2 , 3 , 4 )) .toBe (( 2 + 3 ) * 4 ) // pass こういうアプローチであればテスト以外で依存する関数を用意しなくて良いので Usecase 層がFatにならないため、こういったアプローチを採用していれば良かったなと思っています。 外部 API に対するモックがつらい レイヤー構造の紹介に載せた図ですが、この図を見てわかるようにコントローラー層からリポジトリ層の利用には DI を利用していません。これはコントローラー層では、実際のデータベースを使ったテストをしたかったことが理由です。 一方、こと外部 API に依存する処理に関しては実際にリクエストを送るのではなく、テストケースに応じた API レスポンスへ差し替える必要があります。DI を利用しない仕組みづくりをしてしまったので、差し替えるために Jest の mock が多用されており、辛い状態になってしまいました。 msw 等を使ってネットワークのレイヤーをモックをするような方針を取るか、コントローラー層からも DI ができる仕組みを整備すべきだったなという後悔があります。 VSCode で Go To Definition が使いにくい 関数での DI だとよくある話なのかもですが const usecase = defineInjectable ( { fetchUser , } )(( deps ) => async () => { deps.fetchUser // <== こいつ } ) の「fetchUser」の実装へ定義ジャンプをしたくて「Go To Definition」しても DI 対象を宣言している 2 行目にジャンプしてしまい、実装を読みにいけません。 依存性逆転されて実装ではなくインタフェースに依存しているので当然といえば当然ですが、実装開始して当初は少し困りました。 対策としては、インタフェースにジャンプすれば良いので「Go To Type Definition」でジャンプしてあげると実装に飛ぶことができます。 型と単体テストによるフィードバックが高速で体験が良い テストを先に書いて実装を後から書いていく TDD の開発体験は、実際に Web API を呼び出して試すよりも実装があっているかのフィードバックが速い、という点でとても開発体験が良いです。 型も基本的にはテストと構造が同じだと思っていて チェック速度 チェックできる範囲 型チェック とても速い 型の範囲のチェック DB 依存なしテスト 速い ロジックの検査 DB 依存ありテスト 遅い DB の動き含めて包括的に のような特徴があります 柔軟な型を用いて関数の引数・戻り値の型を正確に・先に用意しやすいので、先にインタフェースやテストを用意しつつ、実装しながら型チェックと DB 非依存のテストで FB をもらえるのでとても体験が良かったです。 まとめ NestJS における TS Backend 設計の一例と Tips を紹介しました。 TypeScript の型システムを活かしながら、型駆動・関数型プログラミングのエッセンスを軸に 型システム上抜け穴になりやすい NestJS のポイントを防ぐ方法 副作用をレイヤー構造で分離してテスタビリティ・開発体験を高められること 準正常系の例外を return し、異常系の例外を throw することで実装をシンプルに保ちつつ、例外を型安全に扱えること 等を紹介しました。 部分的にでも TS Backend 設計の参考になれば幸いです。 設計・執筆の参考にした文献 Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F# by Scott Wlaschin TypeScript による GraphQL バックエンド開発 - Speaker Deck TypeScript の異常系表現のいい感じの落とし所 | DevelopersIO Documentation | NestJS - A progressive Node.js framework Introduction - Mock Service Worker Docs no-restricted-imports - ESLint - Pluggable JavaScript Linter Prisma で本物の DBMS を使って自動テストを書く - mizdra's blog *1 : あくまで筆者の個人的な解釈です。 *2 : ここで言うEntityはO/R Mapper的な文脈ではなく、クリーンアーキテクチャにおける「ドメインモデルと紐づくビジネスルールがカプセルバされてるもの」という意味です。 *3 : Pipe の設定は必要です。 *4 : 任意のprivateなプロパティを持つBaseEntityを継承させることで逆にクラスインスタンスに統一させるという選択肢も有ると思います。このやり方だとObject.assignやplainToClassが型安全性を損なうハッチになってしまうことが想定されるため弊チームでは避けています。しかしconstructorをちゃんと書くようにもすれば冗長性と引き換えに健全な形で運用できると思います。 *5 : 一般的にはロギング等も副作用に分類されます。 *6 : 副作用は引数からのコールバック関数に含まれていて関数の責務範囲には副作用がない・テストにおいて副作用がないことを指します。
アバター
pnpm には Docker でキャッシュを利用しやすくする fetch というコマンドが用意されています この記事では pnpm fetch を使ってキャッシュを利用しやすい Dockerfile を書いていく方法を紹介します Docker のマルチステージビルドとキャッシュ Docker にはマルチステージビルドという機能が存在し、単一の Docker イメージ下で実行するのではなく、ビルドやインストール, 本番実行等に分けて Docker イメージ を作成できます Dockerfile でマルチステージビルドを行う際の例を示します ARG NODE_VERSION=18.15.0 FROM node:$NODE_VERSION-buster as builder # build 処理 RUN pnpm build FROM node:$NODE_VERSION-slim as runner COPY --from=builder /app/node_modules /app/node_modules COPY --from=builder /app/dist /app/dist CMD [ "node" , "dist/index.js" ] 使い方としてはこんな感じですね ステージを分ける目的としては、一般的に ビルドステップでのみ必要な依存関係(devDependency)やバンドルする場合は node_modules 全体等を本番で使うコンテナに持って行きたくない ビルドステップではフルイメージを利用したいが、runner ではできるだけ軽量にしたいため slim イメージを利用する ネットワーク転送が原因で時間のかかる処理が複数あるときに、並列で実行したい (COPY 元が前のステージになっていない、かつ DOCKER_BUILDKIT=1 が設定されているとき並列で実行されます) 等があります このステージ分けはキャッシュの単位としても機能していて、COPY 元のファイルがキャッシュキーになります つまり FROM node:$NODE_VERSION-buster as installer COPY package.json pnpm-lock.yaml ./ RUN pnpm i --frozen-lockfile RUN pnpm build FROM node:$NODE_VERSION-buster as builder # ... の場合は、package.json と pnpm-lock.yaml に変更が入らなければ installer ステップはキャッシュを利用してスキップできることになります ですので、pnpm に限らず npm や yarn 等の他のパッケージマネージャーを利用している場合でも package.json とロックファイルを installer としてステージを分離してあげるとキャッシュが適用されやすくなります pnpm fetch pnpm fetch は pnpm-lock.yaml から依存関係の取得するコマンドです 上の見出しで installer を分離するテクニックを紹介しましたが、pnpm fetch を使うことで、さらに package.json をキャッシュキーから取り除き、pnpm-lock.yaml のみをキャッシュキーとして依存関係をダウンロードできます 設定ファイルとして package.json を利用するツールも(最近は減った気がしますが)ありますし、scripts も package.json に書かれます pnpm-lock.yaml のみから取得できると純粋に依存関係が変わらない限りキャッシュを利用し続けられる、ということになります 公式ドキュメントの Dockerfile が動かない ここからが本記事の本題です 上記の説明は 公式ドキュメントの pnpm fetch のページ にサンプルの Dockerfile とセットで説明されています 公式の推奨するサンプルは以下の通りです FROM node:14 RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm # pnpm fetchはロックファイルのみが必要 COPY pnpm-lock.yaml ./ # パッケージにパッチを当てた場合は、pnpm fetchを実行する前にパッチを含める COPY patches patches RUN pnpm fetch --prod ./ RUN pnpm install -r --offline --prod EXPOSE 8080 CMD [ "node" , "server.js" ] しかしながら、これをそのまま手元で動かすと上手く行きませんでした No projects found in "/app" といわれてインストールされず、node_modules 以下には .modules.yaml と .pnpm のみ作成される おそらく package.json がないことで install 時にプロジェクトを認識できていないことが原因なので、空の package.json を生成してみる → 指定なしのときは pnpm-lock.yaml が空になる frozen-lockfile でインストールしてみる エラーになる pnpm fetch 自体は意図通り動いていますが、おそらくバージョンアップ等で package.json がない状態での pnpm install -r --offline --prod が実行できなくなったものと思われます fetcher step と builder step に分離する install が動かなかったので、回避策として fetch, install と build ではなく、fetch と install, build に分離しました node_modules 以下への展開は install で行われますが、fetch の時点でダウンロードは完了しているのでここまでキャッシュできれば install も十分高速に実行されることが期待できます 分離後の Dockerfile は以下の通りです ARG NODE_VERSION=18.15.0 FROM node:$NODE_VERSION-buster as fetcher WORKDIR /app COPY pnpm-lock.yaml /app RUN corepack enable pnpm RUN corepack prepare pnpm@8.2.0 --activate RUN pnpm fetch --prod ./ FROM node:$NODE_VERSION-buster as builder WORKDIR /app COPY --from=fetcher /root/.local/share/pnpm/store/v3 /root/.local/share/pnpm/store/v3 COPY --from=fetcher /app/node_modules /app/node_modules COPY --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml COPY package.json /app RUN corepack enable pnpm RUN corepack prepare pnpm@8.2.0 --activate RUN pnpm i --offline --prod --frozen-lockfile FROM node:$NODE_VERSION-slim as runner # ... pnpm fetch すると仮想ストアにダウンロードされるので仮想ストアが格納されるパスもコピーしてきます パスは以下のようにして調べられます $ docker run node:18.15.0-buster bash -c "corepack enable pnpm && corepack prepare pnpm@8.2.0 --activate && pnpm store path" Preparing pnpm@8.2.0 for immediate activation... /root/.local/share/pnpm/store/v3 これで「依存関係が変わらない限り依存関係のダウンロードをキャッシュする」ことができるようになりました まとめ このエントリでは pnpm fetch を使うことで、依存関係のダウンロードをキャッシュしやすい Dockerfile について紹介しました install 処理は --offline であっても package.json がないと動かないので fetch までで STAGE を分離し、node_modules と Virtual Store をコピーすることで対応しました pnpm をお使いの方はぜひお試しください
アバター
メリークリスマス 🎉 BC チームの id:d-kimuson です。アドベントカレンダーもとうとう最終日となりました! 今年のアドベントカレンダーでは、初日の記事は僕が執筆をしました この記事を書いていて、レビューをお願いしていたら以下のような投稿をもらいました 社内ではドキュメントサービスとして DocBase を使っているので、技術ブログの下書きを DocBase に書いていたのですが、Pull Request で行うレビューに比べてレビューがしづらいよね、というものです 課題があれば解決するのがエンジニアです かねてからローカルで書けたほうが執筆体験良いのに... と思っていたこともあり ローカルで記事を執筆できる GitHub 上でレビューができる ような仕組みを整えて、今年のアドベントカレンダーのお試し運用をしてみたので紹介します ローカルで執筆できる環境の整備 エンジニアに限れば使い慣れたローカルの環境のほうが記事を書きやすいという人の方が多いでしょう また、モバイルファクトリーのテックブログは、はてなブログで運用していて元々 Markdown を使えることから、下書きはローカルで書くという選択肢も取ることができました 各々のエディタのプレビュー拡張機能等で確認してもらっても良かったのですが、プレビューのスタイルもテックブログに近いほうが嬉しいよね、ということで、ローカルで動作するプレビュー用の開発サーバーも用意しました ブログで実際に利用しているカスタム CSS をあてることで、DocBase を使うよりも本番に近いプレビューをしながら記事を書くことができるようになりました 🎉 技術的には JAMStack 系の各種フレームワーク等の中でも、特にローカルでの執筆体験の良い VitePress を利用してローカルで使うプレビューサーバーを準備しました 執筆補助ツールを利用する また、ローカル環境では prettier (フォーマッター) textlint (日本語校正) cspell (スペルミスのチェック) 等の優れたツールを執筆の補助として利用できるという利点があるので、これらを活かせるような仕組みを作りました 社内で利用者の多い VSCode の設定をリポジトリからまとめて配布することで onSave 時に textlint, prettier で自動修正可能な問題が修正される onType でスペルミスや日本語の問題点をそれぞれ cspell, textlint の拡張機能が教えてくれる ような執筆体験で記事をかけるようになりました また、今回は CI を整備する手間まで取れなかったので husky lint-staged を使うことで、コミット時に prettier, textlint, cspell の制約を課す仕組みを追加しています これによって、VSCode を利用していない社員も、コミット時に校正ツール等の恩恵を受けることができるようになります GitHub 上でレビューができる 構築した環境のソースコード・記事ファイルは GitHub で管理されていて、Pull Request を作ることで記事のレビューを依頼できます コメントをいれる場所が明確ですし、ちょっとした内容であれば Suggested Change を使って提案できるのでレビューもしやすくなりました 自動デプロイはしない GitHub で記事を管理する話になれば、必然的にマージのタイミングで CI/CD を使って記事を自動投稿したいという話にもなってきます セルフホストしている SSG ベースのブログとかでしたらこの辺りはやりやすいんですが、モバイルファクトリーのテックブログははてなブログで運用をしているためこの辺りの仕組みを作るのは結構大変になってしまいます 今回はお試し運用であることと、仕組みを作ることが大変であることから「執筆・レビューのフローまで整備するが、実際に投稿(デプロイ)する作業は手作業で行う」という形での運用としています 使ってみた感想を聞いてみる 元々お試しでやってみようという温度感だったので、実際に記事を書いてくれた人に感想を聞いてみました! ローカル執筆とリポジトリでのレビューのフローを今後も利用したいですか? 約 9 割が今後も利用したいという回答でした 校正ツールである textlint や cspell 等を導入していましたが、執筆の補助として役に立ちましたか? 校正ツールについても 7 割弱は好意的でした その他感想としては GitHub 上のリポジトリという形で記事が管理されることによって、各々が好きなエディターを使うことが出来るようになり、記事を書くのが快適になりました。 初の技術ブログ執筆でしたが、使い慣れた GitHub 上レビューフローでとてもやりやすかったです。 基本的な日本語の誤りも自動で指摘してもらえて助かりました。 のようなポジティブな感想が多かったです レビュワー視点でも 下書き画面や DocBase だとコメント箇所の伝え方が難しいですが、GitHub を使うと行に対してコメントできるのがよかったです コードレビューと同じ要領で記事のレビューができるので、使い勝手も分かっていてよかったです のようなポジティブな感想をいただきました また、個人的には、今回のリポジトリに対して機能追加やバグ修正の PR を送ってくれる有志もいたため、普段開発業務で関わらない人のレビューをしたりすることもあったのが新鮮で良かったです 一方、textlint に関しては設定しているルールが合わないこともあり、以下のような感想もありました textlint と cspell はスペルミスや構文ミスの発見などで何回か役に立ちました!しかし、自分の文章に対するこだわりと相なれない部分もあり、CI で拒絶するほどではないと思いました。以下は textlint で気になった点です。 「思います」という文章は、技術ブログの導入や最後の感想の部分で登場することは自然です。「本格的に、厳格に、丁寧に、文章を書く必要があります」のようなリズミカルに「に」を重複させたいときも怒られてしまいます。「A や B、C、D などのように、〜〜」これも「、」を 4 回打つため怒られます。 この辺りは、人や記事によって語調も変わるので、場合によっては煩わしくなってしまうこともあり、ルール設定が難しいなと感じました textlint では部分的に textlint を無効にする <!-- textlint-disable --> コメントが用意されているので、ルールの整備は進めつつも、一旦はコメントを使ってもらう形で案内するしかないかなと思っています また、画像の追加についてもローカル環境だと手間になってしまうという声や、部署によってはローカルマシンで開発をしないため環境構築が手間だったという声もありました この辺りは今後の課題として解決していきたいですね まとめ この記事では、はてなブログで運用しているモバファクテックブログの記事の管理を GitHub に載せて、ローカルで執筆する仕組みを作った話を紹介しました! たかが執筆・レビュー体験の話ではありますが、会社の名前でテックブログを書いてくれる人はとても貴重です。少しでも書きやすい環境を整備してみるのはいかがでしょうか! NextAction としては やはり執筆した記事を手動で転記するのは手間なので自動でデプロイするような仕組みを整えること 運用していてこの辺も textlint で怒ってほしいよね、この辺は怒らなくても良いよね、というものが見えてきたのでルールを最適化していくこと 執筆体験が良くなっただけでは意味が薄いので発信自体も増やしていきたい 辺りをやっていきたいなと思っています 以上となります! それでは良いお年を!
アバター
🎄モバイルファクトリー Advent Calendar 2022! 毎週土曜日は「良いモノ」を作る技術というテーマで、モバファクの非エンジニアが知見やTipsをお届けします! こんにちは。「駅メモ!」開発チームディレクターの id:Torch4083 です。 この記事では、 エンジニア以外の職種による勉強会を増やすメリット について改めて整理し、実際に開催する際に役立つ事例を添えてお伝えいたします。 まえおき:モバファクの社内制度 それって、エンジニアはなにがうれしいの どうやってるの 勉強会のケース 輪読会 LT会 ワークショップ プレゼン・講義形式(+ 質疑応答) 具体例 ゲームプランニング勉強会 『イシューからはじめよ』輪読会 おわりに まえおき:モバファクの社内制度 弊社には「1日の業務時間のうち1時間は勉強に充ててOK!」という制度があります。(「シェアナレ」と呼ばれています) 業務や個人で学んだことを積極的に社内へ共有することが推奨されており、職種・年次に関わらず制度を利用することができます。 「勉強会といえばエンジニア」というイメージがつきがちですが、弊社は上記制度の後押しがあり、それ以外の職種が主体となっている勉強会も頻繁に開かれています。 直近でエンジニア以外の職種により開かれたテーマは、以下のようなものがありました。 自社ゲームを題材にした、ゲームプランニング入門 自社プロダクトにおけるキャラクター制作 人事・労務制度についての理解を深める それって、エンジニアはなにがうれしいの エンジニア以外の職種に社内勉強会を開いてもらうメリットとしては、やはり 開発以外の業務にも幅を広げることができる・意見できるようになる ことが大きいと思います。 例えば「もう少し上流から開発に関わりたいな……」と考えて自分で企画を持ち込んでみたけど反応が芳しくなかった、という体験をした方はいませんか? その場合、おそらく企画の良し悪しというよりは、企画を考える前提が理解できていなかった可能性が大きいです。(自社サービスのユーザーの傾向、具体的なKPIの推移を踏まえた予測、プロダクトのあるべき姿etc.) (※もちろん、施策立案者が間違っていることも多分にあります。そんなときはぜひ遠慮なく指摘してください!!) 上記のケースの場合、「意思決定のためにはどんな材料が必要で、どこを見せれば説得できるのか?」は、おおよそディレクターなどの施策立案者の間で交わされるやりとりで完結してしまうため、普段の開発業務では分かりにくい部分かと思います。 そのため「だったらそれを直接聞ける機会を作ろう!」というのが、エンジニア以外の職種に社内勉強会を開いてもらうメリットになります。 つまるところ、 他職種主体の勉強会は、エンジニア以外がどんな思考のプロセスで企画や施策を持ってくるのか(あるいは働いているのか)を理解するきっかけになる ということです。 そういう訳で、技術的な話以外で勉強会をやってみませんか?という話が以下に続きます。 どうやってるの ここからは冒頭で触れた通り「社内勉強会のケースと進め方」を紹介し、エンジニア以外の職種が勉強会を開催しやすくなるようなヒントを提供します。 「勉強会をやりたい気持ちはあっても、どういう風に進めたらよいか分からない!」 「勉強会の開き方が分からないからそもそも依頼のイメージがつかない!」 上記のような悩みに対する回答になれば幸いです。 勉強会のケース 輪読会 ■ 概要 最も手軽に進められるのは、本を用意するだけで開催できる輪読会形式です。 (もっとも、本の選定は悩みどころですが……。) 輪読会は、チームや参加者間で共通言語を作るきっかけになります。 この会の最終的な着地点は読んだ本の感想をシェアしたり意見を交換したりすることですが、基本的な進め方としては以下の2種類があります。 参加者全員が同じタイミングで同じ本を読む 参加者にそれぞれの担当を割り振り、要約してきてもらう 前者はより活発な意見交換を促すことができるため、ビジネス書などの一般的な内容の理解を助けてくれます。後者は深い知識の習得・理解に向いているため、どちらかといえば専門書などを題材にする際に役立ちます。 ■ 開催にあたって 輪読会を開催する際によくある困りごとは、以下が多いかと思われます。 本を読む時間がない アウトプットの方法が分からない 前者を解決するには、輪読会の時間に書籍を読む時間を予め設けておくのがおすすめです。章やページの区切りをその時間で読み切れるくらいに設定しておくことで、参加者のハードルを下げることができます。 後者を解決するには、感想を記載するためのテンプレートを使用することが効果的です。 例えば弊社では、以下のようなものを使用しています。 1. 印象に残ったことを箇条書きで3つ取り上げる 2. その詳細を取り上げた項目の下に書き、強調したい部分にハイライトをあてる 3. 2.で取り上げた項目を「過去の自分はどうだったか?」という視点で深掘りする 4. 3.で深掘りしたものの中から、次に自分が実践したいものを選ぶ LT会 ■ 概要 LT会(= Lightning Talkの略)は、1人5分〜10分程度の発表を複数人で回す形式で行われます。個人の興味のあるトピックや近況報告など、大まかなテーマが最初から決まっていることが多いです。 例えば弊社では、以下のようなテーマでLT会が行われました。 今年1年間の業務を振り返りつつ紹介する 管理系の職種は、普段どんな仕事をしているのか? 今までの仕事における「しくじり」について こうした機会を設けることで普段の業務ではできないようなラフな質問がしやすくなり、施策や他職種の仕事への理解を深めることができます。 ぜひ、コミュニケーションの一手段として活用してみてください! ■ 開催にあたって LT会を開催する際によく起こるのが、登壇者が集まりにくく開催が難しくなることです。 これを解決するには、もちろん社内でのこまめな告知も重要になるのですが、開催時期を考えることも重要になります。 例えばLTのテーマを「第3クォーターで印象に残ったこと」「夏期休暇で触れたプロダクトと印象に残った点」などに絞り込むことで、参加者は無数にある話題からネタを絞り込んで考える必要がなくなります。(テーマを自由に設定するよりは最初から決まっていた方がやりやすい気がしますが、みなさんはいかがでしょうか?) ちなみに資料作成については、(エンジニア界隈では有名な?)「 大体いい感じになるテンプレート 」などを使用すると大体いい感じになるようです。 ワークショップ ■ 概要 ワークショップは、主催者があるテーマに沿っていくつか課題を用意し、参加者が取り組んだ成果についてフィードバックや議論を行う形式で行われます。個人の興味のあるトピックや近況報告など、大まかなテーマが最初から決まっていることが多いです。 弊社で行われたワークショップの例を紹介します。 身の回りのものやサービスから、UXについて掘り下げを行う 自社ゲームのイベントで配付するアイテムの種類と量を考えてみよう ■ 開催にあたって ワークショップは、課題を用意する工数が膨らんでしまうのが悩みどころです。 ひとつの解決法としては、 「体験してほしいものは何かを考え、普段の業務を極限まで単純化する」 ことが考えられます。 例えば上で挙げた「自社ゲームのイベントで配付するアイテムの種類と量を考えてみよう」は、ディレクターが行っている業務の一部を切り出して課題化したものになります。 普段はスプレッドシートにいちから作っていくものを穴埋め形式にしてみたり、必要なデータや資料を予めこちらで揃えておいたりするなど、体験してほしい事柄に結びつかないタスクはすべて削ぎ落としました。 「あれもこれもこの機会に知ってもらおう!」というよりは、 「他はいいからこれだけは知ってほしい!」というものを抽出する ことがワークショップ開催の第一歩です。 プレゼン・講義形式(+ 質疑応答) ■ 概要 個人が作成したドキュメントやスライドを用いて発表を行い、参加者が質疑応答を行う一般的な形式です。 ■ 開催にあたって こうした形式の勉強会は発表者にかかる準備面での負担が大きいため、ワークショップ形式と同様に開催が難しくなりがちです。 もし開催したい場合、以下のような方法を取ると工数を抑えられるのでおすすめです! まずはLT会から開催し、そこで発表したテーマを掘り下げる 聞きたいテーマに対する質問を予めまとめておき、登壇者にその場で答えてもらう形式を取る 具体例 ここまでは勉強会の手法や、開催コストを下げる方法についてお話しました。 よりイメージがつきやすくなるように、以下で実際に開催された勉強会の例を紹介いたします。 ゲームプランニング勉強会 ■ 種別 ワークショップ ■ 概要 自社のプロダクト「駅メモ!」を題材としてゲームデザインの初歩の初歩に触れることで、プロダクトやディレクターの業務・思想への理解を深めてもらうことが目的。簡単なワークから運営視点を身につけ、ゲームにおけるプロダクト分析の足がかりとする。 ■ 準備したもの 実際の業務で使用するスプレッドシートを簡略化したもの 会の進め方に関するドキュメント ■ 会の流れ 主催者(ディレクター)から、自社ゲームにおけるアイテム配付の思想について解説(5min.) 直近開催したゲーム内イベントなどの事例に基づいて、アイテムの需要や優先順位について認識合わせを行う(5min.) スプレッドシートで空欄になっている箇所に、ユーザーに配付するアイテムの種別と量を入力する(15min.) 実際のイベントの事例を見せながら、個別でフィードバック(5min.) その他質疑応答(Slackで随時受付) 『イシューからはじめよ』輪読会 ■ 種別 輪読会 ■ 概要 書籍『イシューからはじめよ』を通して、普段の業務の進め方や施策検討の仕方について振り返りを行う。 ■ 準備したもの 参加者が感想を記載するドキュメント 本(及び書籍購入支援制度についての紹介) ■ 会の流れ 主催者が指定した対象のページを事前に読んでくる(開催前) テンプレートに沿って、ドキュメントに感想を記入(10min) それぞれの感想について掘り下げ・討論を行う(20min) ■ 補足:使用したテンプレート この会では、以下のテンプレートに沿って感想の記入を行いました。 ビフォー:「この本を読む前の私は〇〇でした」 気づき:「この本を読んで私は、〇〇について気づきました」 To Do:「今後、〇〇を実行していこうと思います」 おわりに この記事では、以下を紹介しました。 エンジニア職以外が主催する社内勉強会によって、エンジニアが得られるメリット 社内勉強会の主なケース・開催時に気をつけること 弊社における事例(サンプル) 「良いモノ」を作るための一手段として、チームでの共通言語を増やし相互理解を深めるきっかけ(=社内勉強会)を作ってみてはいかがでしょうか? もし社内のメンバーに提案してみて「どう進めていいかわからないから勉強会が開けない!」と言われたら、この記事を紹介してみてください。
アバター
こんにちは、 id:yunagi_n です。 本日の記事は React のお話です。 React で良い感じにスクロールしてくれるライブラリで、有名なものに react-scroll というものがあります。 これは、 JS からライブラリのメソッドを呼び出すことで、もしくは組み込みコンポーネントを使うことで、アニメーションさせながら自動的にその場所に行ってくれて便利なのですが、 iOS Safari でのみ発生するバグがあるので、そのワークアラウンドを紹介します。 バグの内容 以下のように、メソッド経由で ID 要素をつかってスクロールさせる場合、 iOS Safari の 15.4 以降でアニメーションされません (参照: fisshy/react-scroll#502 )。 ただし、 Android や PC Chrome などでは正常に動作します。厄介ですね。 import React , { useCallback } from "react" import { scroller } from "react-scroll" const SomeComponent: React.FC = () => { const onClickScrollToItem = useCallback (() => { scroller.scrollTo ( "item" , true ) } , [] ) return ( < div > // ... とても縦長な要素 < div id = "item" > なにか < /div > < /div > ) } ワークアラウンド さすがに iOS 限定で動かない、というのも困るので、ワークアラウンドで回避しましょう。 といってもやり方は簡単で、単純に自前でスクロール位置を計算してあげれば良いのです。 import React , { useCallback , useRef } from "react" import { animateScroll as scroller } from "react-scroll" const SomeComponent: React.FC = () => { const elem = useRef < HTMLDivElement >( null ) const onClickScrollToItem = useCallback (() => { if ( elem.current ) { const toY = elem.current.getBoundingClientRect () . top + window .scrollY scroller.scrollTo ( toY ) } } , [] ) return ( < div > // ... とても縦長な要素 < div ref = { elem } > なにか < /div > < /div > ) } これで、 iOS Safari 15.4 以降はもちろん、その他のプラットフォームでも正常に動作するようになりました。 お疲れ様でした。
アバター
はじめに id:wgg00sh です。 この記事では、2022年9月にリリースされた iOSの新バージョン 16.0 に向けて、駅メモ!の地図クライアントで行った対応について紹介します。 駅メモ!の地図について 昨年度のアドベントカレンダー で紹介していますが、駅メモ!のアプリ内地図は mapbox-gl-js を使用しています tech.mobilefactory.jp iOS16で発生していた問題 2022年9月頃、正式にリリースされる前のβ版iOS16で駅メモ!の動作を見ていたところ、意図しない表示になることがありました。 そして、特に地図を開いた直後にその問題が発生しやすいとわかったため、自分は地図の負荷を減らす方向で問題の軽減を進めました。 mapbox-gl-jsの Safari における問題 こちらの issue に問題の詳細が書かれています。 mapbox-gl には、 map.remove() という終了用のクリーンアップを行うAPIがありますが、この処理に問題があり Safari では正常にクリーンアップされずメモリリークが発生してしまいます。 ここでは Safari と書いていますが、 iOSにおいてはサードパーティ製のブラウザもSafariと同一の WebKit を使用しているので、 例えばWebブラウザで動作するアワメモ!を iOS Chrome でプレイしていてもこの問題は避けられません。 また、駅メモ!では mapbox-gl-js の v1.13.0 を使用していましたが、当時バージョンの v2.10.0 でもこの問題は解消されていません。 iOS16対応以前 (~2022/09) このSafariの問題に対する解決策が見つかっていなかった当初は駅メモ!内で地図の開閉を繰り返すことで動作が極端に重くなったり、Webページがクラッシュして強制的にブラウザがリロードされてしまう事がありました。 そこで地図画面に以下の暫定的対処をしていました。 地図を閉じた際にmapbox-glのインスタンスを破棄せず全て保持して、再度地図を開く場合にはそのインスタンスを再利用するというものです。 これによって地図を複数回開くことによるメモリリークを防ぐことはできたものの、地図を使用していない間もメモリを食っておりパフォーマンス的にはあまり良い状態とは言えませんでした。 iOS16ではこの状態で駅メモ!をプレイし続けると、他の問題も合わせて表示の不具合が発生しやすいとわかったため、この問題を解消することになりました。 解決 この問題に対して他にも苦戦していた方がいたのか、偶然同時期に 同じ問題に関するPR が他の方から上げられました。 diff を見ると、webglcontextlost などの webgl周りのイベントを破棄できていなかったり、canvas の初期化ができていなくてGCが機能していなかった様に見えます。 このPRと同等の内容を、 駅メモで使用しているmapbox-gl-js v1.13 に適用して解決を図ります。 駅メモ!で動作を確認 修正を適用した mapbox-gl を使用して、実際に駅メモ!上での動作を確認してみます。 修正前 修正後 Safariのインスペクタを開いた状態で地図の開閉を繰り返し行ったところ、修正後は地図画面を閉じると使われなくなった Canvas が削除されるようになりました。 また、開閉を繰り返すことでブラウザが強制リロードされる問題も発生しなくなることが確認できました。 終わりに iOS16がリリースされるにあたって、駅メモ!で行った対応は地図だけでなく他にもたくさんありましたが、今回は自分が担当した地図の問題解消について紹介しました。 とはいえ偶然同時期に同じ問題を解消された方が居てそれを利用させていただいた形でしたので、このPRが上がっていなければiOS16 がリリースされるまでに解消するのは困難でした。 また、今回導入した実装は現状 iOS16 で駅メモ!を実行した場合にのみ動作するようになっています。 iOS15以前では引き続きmapbox-gl のインスタンスを保持して、地図を開き直す場合には再利用する形になっています。
アバター
駅メモ!開発チームエンジニアの id:yokoi0803 です。 駅メモ!チームで運用している「駅メモ! - ステーションメモリーズ!-」は今年で 8 周年を迎えました。 スマートフォン向けゲームとしては長く続くサービスとなりましたが、長期運用に伴ってそのコードベースは大きく、複雑になり、保守性の面での課題が段々と無視できなくなってきています。 しかし課題だと認識されているにも関わらず、その改善、つまりリファクタリングを行う機会は少なく、結果としてコードの複雑さは増す一方になっています。 この記事では、上記の問題はなぜ起きているのか、現状を再認識し、問題を解決するために考えたことを記述します。 直接、技術に関わる話はありませんが、読んでみて、自分のチームやプロダクトはどうなっているのか、思い返すきっかけになれば幸いです。 開発チームの構成 後の話を理解しやすくするため、まず開発チームの構成について軽く説明します。 開発チームの主な役割は駅メモ!内のコンテンツであるでんこやイベントの開発、その他機能の追加や修正です。 技術職(エンジニア)と非技術職(ディレクター、マネージャー)で構成され、チームで行うタスクはほとんど非技術職が主体となって決定しています。 エンジニアはそれらタスクのうち、主にコーディングが関わる部分を受け持っています。 現状の整理 記事の冒頭で述べた通り、当プロダクトのコードは保守性の面で課題を抱えています。 今回は保守性に影響を与える要素として「技術的負債」に焦点を当て、現状を整理しました。 技術的負債の発生 開発チームの業務の忙しさには年間を通して波があります。 繁忙期にはエンジニアもほぼフル稼働することになり、開発における余剰リソースがない状態になります。 そのような状態でコードの複雑性と納期が相まって、どうしても「その場しのぎ」な実装にせざるを得ないことがあります。 また、当時最善だと思った実装だとしても、機能追加などでコードが変化していくにつれて最善ではなくなっていたというケースもあります。 技術的負債の解消 繁忙期がある一方で閑散期もあり、このタイミングでは業務の改善をチームで積極的に取り組んでいます。 ただし、先述したようにチームのタスクは非技術職が主体となって決定するため、どうしても非技術職の視点から見える範囲の作業効率化などが優先されてしまいます。 エンジニアとしても、技術的負債があるからと言って直ちに問題があるわけではないため、その解消をすることの優先度を下げてしまっています。 ここまでのように現状をまとめると、 技術的負債は溜まっていく一方になっている ことが明らかになりました。 問題はどこか 技術的負債が生まれることはビジネス上、避けられないものです。その前提で考えると、生まれた負債の解消、つまりリファクタリングをする機会を作っていない事が問題でしょう。 ではなぜ機会がないのかというと、 技術的負債が溜まっていることを、非技術職を含むチーム全体が課題として認識していない からだと考えました。 チームとしての課題にするには コードベースの詳細な事情はエンジニアしか知り得ませんし、非技術職に対してそのまま説明しても成果が目に見えるような課題と優先度比較をできず、チームとしての課題に上げることは難しいです。ですから、お互いにとって共通の概念であるプロダクトを基準にしてみます。 このままだとどのようなリスクがあるか、リファクタリングの意義は何か、考えてみました。 このまま技術的負債が溜まるとどうなるか 複雑さ故に実装速度が下がり、バグの混入率は上がる 実装中、負債にぶつかる可能性が高くなり、その解消をしなければ作業が進められない場合は想定外の工数がかかる 上記 2 点を考慮すると、作業見積もりは不正確、あるいはマージンを取らざるを得なくなる プロダクトが抱えるリスク 実装速度が下がったり、見積もりが不正確になるため、ユーザに届けられる価値が減少する 不具合の発生率が増加する リファクタリングの意義については、「プロダクトが抱えるリスク」の解消になります。 これで一応、技術的負債の解消をチーム内の他の課題と同列に扱う根拠は得られました。 問題の解決に向けて 技術的負債が溜まっていることは課題であり、リファクタリングの意義についても明らかになりましたが、まだチーム内の成果が目に見えるものと優先度比較をすることは難しいです。 何がどのくらい良くなるのか、定量的に評価ができないからです。 とはいえ、やる意味があることは明らかですし、やってみることで効果を計測できるようになるという考えのもと、弊チームでは定期的にリファクタリングを集中的に行う時間を設けることを考えています。 また、リファクタリングの定量的な評価については別軸で動きがありまして、つい先日の記事にもなっていますので、興味があればそちらも是非読んでみてください。 Perl コードの「複雑さ」を計測する まとめ 弊チームでは現状、技術的負債を解消する機会が存在せず、溜まっていく一方になっている なぜなら、技術的負債の解消が、チーム内の他の課題と同じステージに立てていないから 非技術職では事情を知り得ないので、エンジニアが問題を認識し、それを提起しなければ状況は変わらない 技術的負債が溜まることでプロダクトがリスクを抱えることになる、という職種関係ない概念で認識を合わせるべき とりあえず、リファクタリングの時間を取ってみよう
アバター
こんにちは、エンジニアの id:mp0liiu です。 自分が所属しているチームでは現在もPerl製のプロダクトを運用しており、VSCode で Perl のコードを書いたり触ったりする機会が多いです。 Perl は開発環境が貧弱で他の言語と比べるとあまり開発体験はよくありませんが、それでも少しずつ便利な拡張機能が充実していってるので、この記事では自分が利用している便利な VSCode の Perl 向け拡張機能を紹介します。 Perl Navigator marketplace.visualstudio.com 今年話題になった Languager Server を利用した拡張機能です。 他にも Perl の Languager Server を利用した拡張機能はいくつか種類がありますが、以前から存在する拡張機能と比べると自動補完やコードジャンプがちゃんとできたり、 Perl::Critic 、 Perl::Tidy 、 perlimports をまとめて扱ってくれる点が優れています。 環境にもよりますが、インストールするだけで基本的な機能は動作してくれます。 Perl::Critic と perlimports はバンドルされていないので別途インストールする必要があります。 使える機能はだいたい次のような感じです 文法チェック Perl::Critic によるコードの静的検査 サブルーチンの返り値を return で返しているかどうかをチェックする Subroutines::RequireFinalReturn を有効にしてチェックしたときの様子です 自動補完 コードジャンプ Perl::Tidy によるコードフォーマット perlimports による use 周りのコードのクリーンアップ Object::Pad, Moose など DSL や keyword プラグイン系の構文のシンタックスハイライトへの対応 Perl(the96.vscode-perl) marketplace.visualstudio.com ctags によるコードジャンプおよび自動補完と、Perl::Tidy によるコードフォーマットができる拡張機能です。 利用するには事前に ctags をインストールしておく必要があります。 機能的には Perl Navigator と重複しているのですが、 ctags を利用してコードジャンプや自動補完をしているので精度が悪くなるかわりに動的にモジュールをロードしている場合や型を推測しにくい変数からもコードジャンプや補完が効くといったメリットがあるため、 Perl Navigator と併用しつつ邪魔と感じたら無効化したりしています。 引数をポップアップで表示してくれるという点も少し嬉しいです。 (名前付き引数や引数のバリデーターには対応していませんが・・・) Perl insert package marketplace.visualstudio.com 開いているファイル名と対応したパッケージ名を入力してくれるプラグインです。 コマンドパレットから実行するか、自動補完もオプションで有効にできます。 巨大なプロジェクトだと名前空間が深くなっていちいちファイル名と対応したパッケージ名を手動で入力するのは大変だし typo すると気づきにくくて大変なので重宝しています。 Perl Rename Symbol marketplace.visualstudio.com App::PRT や App::EditorTools を利用して変数、メソッド名、パッケージ名など識別子を正確に rename してくれる拡張機能です。 リファクタリングをするときなどに重宝しています。 別途 App::PRT と App::EditorTools のインストールが必要です。 perl-auto-use marketplace.visualstudio.com まだ use していないモジュールがある場合はファイルの先頭の方で use $module を挿入してくれる拡張機能です。 コマンドパレットから使います。 外部モジュールの関数を使っていてまだ use していない、といった場合も場合も use $module qw( $function ); というようにモジュールを use しつつ利用している関数だけインポートしてくれますが、 同名の関数をもつモジュールが複数あったりすると期待したモジュールが挿入されるとは限らないので注意してください。 Better Perl Syntax marketplace.visualstudio.com デフォルトの Perl の シンタックスハイライトから更に以下の字句に異なった色付けをしてくれるようになり、コードが見やすくなります。 数値 演算子 関数呼び出し 正規表現の文字クラス ^ が先頭につく特殊変数、 $^V など 関数ブラケット デフォルトのカラーテーマはこれらの字句の色付けに対応していないので、Material Theme などのカラーテーマと併用する必要があります。 また、Perl Navigator で有効になる DSL や keyword プラグイン系の構文のシンタックスハイライトにはシンタックスハイライトが効かなくなるので、それらのモジュール使っているときは無効にしています。 シンタックスハイライトにはいろいろ好みがあると思いますが、見やすくなると思うので一度試してみてはいかがでしょうか。 まとめ 以上、自分が利用している VSCode の Perl 向け拡張機能を紹介させていただきました。 ちゃんと型をもっている言語と比べるとどうしても劣ってしまいますが、これらの拡張機能を揃えるだけでもかなり開発体験はよくなるのでぜひ試してみてはいかがでしょうか!
アバター
駅メモ!チームでエンジニアをしている id:stakHash です。 弊社の主力プロダクトの 1 つである駅メモ!は、今年で 8 周年を迎えました 🎉 スマートフォンゲームとしては息の長いサービスですが、現在でも日々様々な新機能の開発が進んでいます。 今後も今以上の速度でユーザの皆様に価値提供をしていくためには、分かりやすく変更しやすいコードベースを維持・改善していくことが必要です。 しかし、「分かりやすさ」「複雑さ」という主観的でぼんやりとした感覚値は、長いライブサービスでは、人員の入れ替わりもあって判断が困難になっていました。 そこで、 「複雑さ」 を定量的に計測する方法を探ってみました。 「複雑さ」とは 今回は、Microsoft 社が主に Visual Studio 内で利用している 保守容易性指数 (Maintainability Index) を扱ってみます。 MAX( 0 , ( 171 - 5.2 * ln( Halstead ボリューム ) - 0.23 * ( 循環的複雑度 ) - 16.2 * ln( コード行数 )) * 100 / 171 ) 式の通り、これは 0~100 の範囲の値になります。低いほどそのコードが複雑で、保守しにくいことを表します。 Visual Studio では、次のように警告する範囲を決定しているようです。 保守容易性指数 警告色 0~9 赤 10~19 黄 20~100 緑 20 が 1 つの基準値となるでしょうか。 さて、2 つの別のメトリクスが出てきましたので、簡単に説明します。 Halstead ボリューム 1 つ目は Halstead ボリューム (Halstead Volume) で、1977 年に Halstead 博士が導入した Halstead complexity measures という一連のメトリクスのうちの 1 つです。 詳しい説明は Wikipedia に任せて、式を見ていきます。 Volume = (オペレータの数 + オペランドの数) * log2(オペレータの種類 + オペランドの種類) 式を見れば分かる通り、コード中の 語彙の複雑さ に注目していることが分かりますね。 Perl においては、 Perl::Metrics::Halstead というパッケージが存在します (駅メモ!のサーバサイドは Perl で実装されています)。 循環的複雑度 2 つ目は 循環的複雑度 (Cyclomatic Complexity) です。 同じく詳しい説明は Wikipedia 先生にお任せしますが、 線形的に独立した経路の数 = 構造的な複雑さ を表します。 Perl においては Perl::Metrics::Simple で計測できます。 計測してみる 本題です。 今回計測したいのは、「複雑さ」という非常に抽象度の高い指標でした。 上記で紹介した「保守容易性指数」は「語彙的な複雑さ」と「構造的な複雑さ」の両面を考慮しており、「複雑さ」の計測に適した指標の 1 つであると考えられます。 これを計測する Perl モジュールが見つからなかったので、上述した 2 つのモジュールを参考に Perl::Metrics::Maintainability を実装しました。 このリポジトリ自体を計測してみると、全て 20 以上をマークしていました。 極端に複雑化していない事が確認できます。 MI LoC cc volume path -------------------------------------------------------------------------------- 39.67 48 14 1287.07 ./lib/Perl/Metrics/Maintainability/Result.pm 39.89 47 11 1460.16 ./bin/perlmi 39.95 49 14 1100.45 ./lib/Perl/Metrics/Maintainability/File/Result.pm 40.56 47 5 1526.19 ./lib/Perl/Metrics/Maintainability/File.pm 46.19 33 5 720.46 ./lib/Perl/Metrics/Maintainability.pm では、駅メモ!の実装を計測してみると、全体の約 2% に当たるファイルが 10 を下回っていました。 改善のし甲斐がありそうですね! (ファイルパスは機密保持の観点から削除しています) MI LoC cc volume path -------------------------------------------------------------------------------- 0.00 489 140 19917.78 0.00 437 208 23967.10 0.00 693 198 33429.84 0.00 216 199 15096.23 0.00 699 58 42585.91 0.00 1259 208 51969.83 0.00 653 116 31531.67 0.00 538 87 24446.56 0.00 866 109 43274.66 0.00 603 104 30685.96 0.00 417 156 22806.22 ... まとめ 今回の計測により、今まで個々人が漠然と「ここは複雑そうだな」と思っていたものが、数値化・順位づけされて見えるようになりました。 実際にどこを改善していくかは、コードを精査する必要がありますが、コードベースの「複雑さ」を抑えていくための目安としては有効に使えそうです。
アバター
駅メモ!チームエンジニアの id:yumlonne です。 この記事ではスーパープロジェクト(サブモジュールが登録されている親プロジェクト)側で git checkout や git pull を実行したときに、自動で git submodule update 相当の処理を実行してくれる便利な設定を紹介します。 git submodule については ドキュメント を参照してください。 記事中の各種動作は git version 2.38.1 で確認しています。 背景 私は最近サブモジュールが存在するプロジェクトを触り始めました。 しかし、git 操作をするときにサブモジュールが存在することを意識していないと、サブモジュールの参照を意図せず書き換えてしまうことがありました。 $ cd MyProject $ git checkout topic-A # サブモジュールをtopic-Aブランチが持っているコミットに向ける $ git submodule update $ git checkout topic-B # ここで git submodule update を忘れると、サブモジュールはtopic-Aの状態から更新されない! $ git add . # 気づかずにコミットすると、topic-Bのサブモジュールの向き先がtopic-Aと同じになってしまう $ git commit -m "hoge" もちろんコミット前の確認やコードレビューがあるので気がつくことはできますが、pull や checkout をするたびに submodule update を打つのも面倒です。 そこで git config を調べたところ、 submodule.recurse というフラグで実現できそうということが分かりました。 このフラグは様々な git コマンドの --recurse-submodules オプションを制御するため、他のよく使いそうな git コマンドに与える影響も調べてみました。 各コマンドへの影響 以下の各コマンドは git config --global submodule.recurse true を設定した上で検証しています。 詳細な影響は man git config や man git ${command} で--recurse-submodules オプションの説明を参照してください。 switch ブランチを切り替えたときにサブモジュールも自動で追従します。 $ git submodule status 5b8930e2a251ead82076cf17cab13b95f0ec392d SubProject (5b8930e) $ git switch topic-A Switched to branch 'topic-A' $ git submodule status 89c87486bd15a4ebc84a7166a46977806ecfaced SubProject (heads/main-1-g89c8748) restore サブモジュールのファイルも復元されるようになります。 $ ls README.md SubProject $ ls SubProject/ README.md $ echo "hoge" >> README.md $ echo "fuga" >> SubProject/README.md $ git status On branch main Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git restore <file>..." to discard changes in working directory) (commit or discard the untracked or modified content in submodules) modified: README.md modified: SubProject (modified content) $ git restore . $ git status On branch main nothing to commit, working tree clean checkout checkout でブランチを切り替えた場合は switch 相当、ファイルを復元した場合は restore 相当の動作になります。 pull サブモジュールの新しいコミットも取得し、必要があれば switch と同様に自動で追従してくれます。 $ git submodule status 61b0cf596df0b2c68617be4a31f0feeca270d3f1 SubProject (61b0cf5) $ git pull origin main From github.com:yumlonne/MyProject * branch main -> FETCH_HEAD Fetching submodule SubProject Updating ae9b6ab..edcc372 Fast-forward SubProject | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) Successfully rebased and updated detached HEAD. Submodule path 'SubProject': rebased into '5b8930e2a251ead82076cf17cab13b95f0ec392d' $ git submodule status 5b8930e2a251ead82076cf17cab13b95f0ec392d SubProject (5b8930e) submodule.recurse true の設定により、 pull のたびにサブモジュールのフェッチが実行されます。 以下のように fetch の config として on-demand を指定することで、変更されたサブモジュールのみフェッチされるようになります。 git config --global fetch.recurseSubmodules on-demand push スーパープロジェクトで push を実行したとき、サブモジュール側のコミットも一緒に push してくれます。 $ cd SubProject/ $ echo "hoge" >> README.md $ git add . $ git commit -m "update README.md" $ cd ../ $ git add . $ git push origin main Pushing submodule 'SubProject' Enumerating objects: 5, done. Counting objects: 100% (5/5), done. Delta compression using up to 2 threads Compressing objects: 100% (2/2), done. Writing objects: 100% (3/3), 322 bytes | 322.00 KiB/s, done. Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 To github.com:yumlonne/SubProject.git 47cd1b1..be119ef main -> main Enumerating objects: 3, done. Counting objects: 100% (3/3), done. Delta compression using up to 2 threads Compressing objects: 100% (2/2), done. Writing objects: 100% (2/2), 237 bytes | 237.00 KiB/s, done. Total 2 (delta 1), reused 0 (delta 0), pack-reused 0 remote: Resolving deltas: 100% (1/1), completed with 1 local object. To github.com:yumlonne/MyProject.git 43ddc9d..9030778 main -> main 他にも、config で push.recurseSubmodules を設定するか、push 時にオプションを渡すことで挙動をカスタマイズできます。(長くなるので省略します) grep サブモジュールのファイルも grep できるようになります。 $ git grep -n hoge SubProject/README.md:2:hoge まとめ submodule.recurse true を設定することで、色々なコマンドがサブモジュールを意識して動いてくれるようになりました。 ここでは紹介していないコマンドやオプションもあるので、使う際はお手元の git のバージョンに対応したドキュメントを読むことをおすすめします。
アバター
🎄モバイルファクトリー Advent Calendar 2022!毎週土曜日は「良いモノ」を作る技術というテーマで、モバファクの非エンジニアが知見やTipsをお届けします! こんにちは。モバファクでマネージャーをしている ゆっぴぃ です。 タイトルにもある通り、エンタメ企業の社員である私が、どのようにエンタメを楽しんでいるのかをブログ記事として書いてみました。 これを書こうと思った背景 社内でチームメンバーからこんな質問を受けました。 「ゆっぴぃさんって、いつこんなにアニメを見たりゲームをやったりしてるんですか?」 メンバーがこの質問をした背景には、普段の業務における会話の中で、私がアニメの話だったりゲームの話をする印象があるからかな(?)と思います。 ただ、その私に対する印象はインプットしている量が多いからではなく、私のアニメ等に関する発言(アウトプット)頻度が多いことが影響しているのではないかなと思っています。 他のメンバーのほうが、たくさんのゲームやアニメを知っているなと私自身は感じています。 ちなみに、「いつこんなにアニメ見たりゲームやったりしてるんですか?」の回答は平凡になるので割愛させてください。 私が実践するエンタメの楽しみ方 「楽しい」のアウトプット とある別のエンタメ企業の方がこんなツイートをしていらっしゃいました。 「エンタメ企業で働くものとして、「自身の思う楽しい」が多くの人に伝わるように言語化することが大事である。」と。 私自身、これは働いている上で実感することが多々あります。 チームメンバーを巻き込み、みんなが「楽しい」と思えるものを作るには、企画者自身が「それがどうして楽しいのか」を話せないといけません。 企画者以外でも「我々自身が作っているものはどうすればさらに良くなるのか」を考えて行動することが、よりよいモノを作るうえでは必要です。 そのため、日常的にエンタメの面白さを言語化することが、よいモノづくりをするうえで必要な訓練であると考えます。 ゲームやアニメはもちろん、遊園地やアウトドアアクティビティなどなど、「楽しい」経験をしたら言語化すること。 これが私が考え実践している、エンタメ企業で働く人のエンタメの楽しみ方です。 実際にどうやっているのか エンタメに触れる時に大切にしていること これまで「アウトプットは大事だ!!」と語ってしまいましたが、私自身、一番大事にしていることは「全力で楽しむこと」です。 個人的には「勉強の一環だ!」と思ってエンタメに触れると(個人的には)心から楽しむことができないです。 アウトプットをすることなど忘れて、目の前のエンタメに全力で向き合っています。 アウトプットのことは楽しんだ後に考えましょう。 そもそも楽しまないと、そのあとのアウトプットも苦労しますよね……? アウトプット時に大切にしていること アウトプットの方法は、あまり真面目に考えないのが吉だと思います。 まず一番簡単なのは、友達など身近な人と話すこと。 映画などに一緒に行った場合、そのあとにカフェやファミレスで感想を話しあうなんてことを経験したことがある人は多いと思います。 これもアウトプットの一つの形です。私もよくやります。 それ以外に私がやっているのは、いわゆるレビューサイト的なところに書くことです。 世の中には便利なサービスがたくさんあって、アニメの感想を書いたり、飲食店の感想を書いたり、エンタメの種類に合わせて自分の記録を残すことができるようになっています。 意外にも、そういうサイトに感想を投稿するとほんの少しばかりですが、知らない人から反応をいただけることもあったりするものです。 私は初めてそういうサイトに投稿したときに反応があるとは予想していなかったので、嬉しい・楽しい気持ちが湧いてきました。 アウトプットの手段は様々です。アウトプット自体も「楽しい」と思えるような方法を地道に見つけるのが個人的にはおすすめです。そのほうが継続できるからです。 その他のPOINT 「楽しい」をさらに深堀るために 「楽しい」を言語化していく上で、どのように深堀りをしていくのかを意識したほうが学びは大きいと思います。 深堀りの方法について簡単に書くと以下の通りです。 ※ちなみにSNSで拾った知識だったりしますので、エンタメ業界の公式なお話ではありません 横に広げていく深堀り:「□□は他の○○と、こういうところが共通していて面白い」 縦に広がていく深堀り:「××が面白いと感じる理由は、▲▲なところにある。そもそも▲だとなぜ面白いかというと~」 私はどちらかというと横に広げる掘り方が得意なタイプです。だからこそ、心持ちとしては、いろんなエンタメに触れようとしています。 他のチームメンバーと話していると、縦に深堀りをするのが上手だと感じる人ももちろんいます。そういう方は一つのエンタメに対しての情熱が人一倍あり、そのエンタメについて話をしている様子を見るだけで聞き手もワクワクしてしまうものです。 最後に いかがでしたでしょうか。エンタメを作ることを仕事にしている社会人のエンタメの楽しみ方でした。 ただ「楽しい」だけで済まさずに、言語化していく姿勢はすごく大事です。 私自身もまだまだ「楽しい」の言語化が得意とは言えないですし、エンタメは日々変化し進化していくので継続して取り組んでいきます。 また、私の所属するチームでは、「楽しい」を共有をする会議があります。厳密には「楽しい」に限定した話ではなく、身の回りにあったことを話す会議です。 その会議には、毎週新しくプレイしたゲームを話してくれる人がいたり、おいしい食べ物の写真を載せてくれるメンバーもいます。 このように「楽しい」の言語化をするタイミングも設けて、チームみんなで良いモノづくりができるよう励んでいます。 ぜひ皆さんも「楽しい」のアウトプットを意識してみてくださいね!それでは!
アバター