Quantcast
Channel: クックパッド開発者ブログ
Viewing all 802 articles
Browse latest View live

【開催レポ】Cookpad Tech Kitchen #25 日本最大レシピサービスのモバイルアプリ開発事情

$
0
0

こんにちは。クリエイション開発部の星川 (@star__hoshi) です。
2020年12月10日に Cookpad Tech Kitchen #25 日本最大レシピサービスのモバイルアプリ開発事情を開催しました。今回は新型コロナウイルスの影響もありオンラインでの開催となりました。

f:id:star__hoshi:20201217101301p:plain

クックパッドには多くのレシピが投稿され、利用者数も多いサービスとなっています。その規模の大きさから、サービスの改善には事業的にも技術的にも独自の困難がつきまといます。

  • コードの品質はどう保つか
  • 日々の業務効率を高めるために使えるツールはあるか
  • ログを始めとした技術基盤をどう整備するか
  • その基盤を活かしてどのようにサービスを開発していくか

今回はモバイルアプリの領域にフォーカスし、このような課題に日々立ち向かっている吉田、ジョセフ、三木、星川の4名が、日々の業務を通して得た知見について発表しました。

発表内容

「基本のAndroid View開発ドキュメント」/ 吉田 万輝 (@_k4zy)

クックパッドではAndroid開発経験やプロジェクトへ関わる期間が様々な人々がいる状況でチーム開発を行っています。
Android開発を効率的に進めるため「誰が書いても大体同じような実装になる」ことを目指し、モバイル開発基盤が主導して整備している開発方針のドキュメントについて発表しました。

基本の Android View 実装ドキュメントの紹介にも詳しい記載があるので、合わせてお読みください。

「Efficient app development using various debugging and verification tools(デバッグや検証ツールを活用した効率的なアプリ開発)」 / Joseph Iturralde

Androidアプリのリニューアルプロジェクトに伴い、開発効率改善のために様々なツールを利用したり、また必要に応じてツールを作成しました。
その時に役立ったツール、テクニック、デバッグ手法やビルドの高速化について紹介しました。

「モバイルアプリ行動ログ基盤を”大統一”した話」/ 三木 康暉 (@giginet)

モバイルアプリ上でユーザーの行動ログを記録する際、従来の方法では、仕様の共有や、ミスを防ぐのが難しいという問題がありました。
この発表では、ログのドキュメントから、モバイルアプリの実装を自動生成することで、安全なログ基盤を高速に構築した事例を紹介しています。

ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築に記事がありますので、合わせてお読みください。

「大統一ロガーを利用したサービス開発」/ 星川 健介 (@star__hoshi)

giginet の発表した「大統一ロガー」によって、ログ基盤が整えられました。
その大統一ロガーはサービス開発者にとってどういうメリットがあるのか、また大統一ロガーを利用してどのようにクックパッドのiOSアプリを改善しているか紹介しました。

Q&A

オンライン開催のため、ZoomのQ&A機能を使いたくさんの質問をいただきました。そのうちのいくつかをピックアップして紹介します。

ConstraintLayout を使うとあらゆる View に id 名をつけると思いますが、id名の命名規則はどのようにしてますか xxxxText なのか textXxxx なのかとか

実はidの命名規則は決めてないのですが圧倒的に xxxxText が多数派です。
とても良いご指摘なのでidの命名規則もドキュメントに記載して統一しようと思います。

ログ定義はプラットフォーム間で共有されていますか?

大統一ロガーに関して、ログ定義はプラットフォーム間で共有されていません。
アプリケーションごとに画面構成が違ったり、ログの要件も違う場合があるので分離しています。

これは、過去の行動ログ基盤の運用を通し、複数のプラットフォームのログを統合すると、デメリットの方が多かったという経験に寄るものです。

ログをみてから仮説を得ることと仮説を確認するためにログをとることのどちらの方が多いですか? また、気になったことをいちいちログとりしていくの大変(=面倒くさい)ではないですか?

ログを見てから仮説を得ることもありますし、仮説を確認するためにログを取ることのどちらもあり、どちらが多いというのは難しいです。
気になったことをいちいちログ取りしていくのは大変ですが、施策を実施する前に「どの指標がどれくらい伸びたら成功とする」というのを決めることが多く、ログの取得はほぼ必須となっています。

感想

オンラインの開催となり、参加者が少なくならないか、質問があまり来ないかもと不安な中でのスタートでしたが、たくさんの方に参加いただき質問もたくさんいただきました。
Android/iOS共にアプリのリニューアルを行い、そこでの知見を発表させていただきました。Markdown でログ定義を作成しそこからロガーのコードを生成すると決定したときは「マジか、yml とかで定義書いてそこからドキュメントとコード生成した方がいいんじゃないか…」と思ったんですが、実際に運用してみると Markdown は書きやすいしすぐにプレビューも出来てとても便利でうまく回っています!

最後に

クックパッドでは、モバイル基盤部とサービス開発の部署が連携して開発しています。
クックパッドのモバイルアプリ開発やサービス開発について、もっと聞いてみたい、ディスカッションしたいという方がいらっしゃいましたら、個別雑談会も実施しているので是非お申し込みください! カジュアルに情報交換しましょう!
お申し込みはこちら → https://enq.cookpad.com/meet_cookpad_engineer


Taking Advantage of Debugging Tools for Android App Development

$
0
0

Hello! I'm Joseph and I'm an Android engineer from the Mobile Infrastructure team.

In this post, I will talk about some of the tools that we used in debugging while working on the renewal project. The contents of this post were presented in the recently held Tech Kitchen #25.

Renewal Project

As mentioned in the article テストケース作成を仕様詳細化の手段とする実験 published earlier this year, the Cookpad app for iOS has undergone a lot of changes as part of the renewal project.

Last October, it was Android's turn and we published the Cookpad app for Android which underwent a similar renewal project. Since this is a fairly large project, it's quite challenging to develop and debug. However, we used some tools that helped us improve our workflow.

Flipper

f:id:epishie:20201223161236p:plain

Some time ago, Facebook published a tool called Stetho, a debug bridge tool build for Android using Chrome Developer Tools. Stetho is quite useful but the functionality is limited and the client is dependent on Chrome. In 2018, they released Flipper to replace Stetho and now works on iOS, Android and ReactNative apps and with its own stand-alone client built on Electron.

Flipper has two components, the app SDK which is added as a dependency to the target app, and the desktop client where users can interact and view the data sent from the app via the SDK. Out-of-the-box, Flipper includes some core plugins users can use right away to inspect various app components with minimal setup but developers can also extend it and add or publish their own plugins.

Layout Inspector

f:id:epishie:20201223161654p:plain

One of the core plugins is the Layout Inspector. This is very useful in debugging complex UI especially when screen content is obtained from an API or user-generated like some of the screens that we've added in the renewal project.

Using Flipper, the view tree of the screen can be checked and verified if the views are inflated correctly.

Client App
f:id:epishie:20201223161759p:plain:w480f:id:epishie:20201223161806p:plain:w160

Selecting the view will reveal the view attributes, which can be updated while running the app without recompiling or restarting. Hovering on the view will highlight the said view within the app running on the device or on the emulator. It also shows within the app the view bounds and margins or paddings if the view has it. This is very useful not only for developers during debugging but also for designers when verifying the design specs. There's a lot of other features like view search and target mode so please check the documentation for more information.

Network Inspector

f:id:epishie:20201223162344p:plain

Network Inspector is also one of the core plugins. Using this tool, HTTP requests sent from and responses received by the app can easily be verified. Out-of-the-box, the Network Inspector plugin can be integrated with OkHttp as an Interceptor that can be added to the client. Since the inspector is added directly to the HTTP client, even HTTPS requests can be inspected without dealing with encryption and certificates. For clients other than OkHttp, integration code can be written by calling the appropriate methods of the SDK's network plugin object. Recently, they added the Mock feature to stub HTTP responses which is very helpful during debugging.

Shared Preferences Viewer

f:id:epishie:20201223163054p:plain:w320

Another core plugin is the Shared Preferences Viewer. In the Cookpad app, there's a custom component called Spotlight that we use when onboarding users to the features that were added in a release. This component consists of a custom view to highlight the new feature and a SharedPreference key-value to keep track of whether the user has been shown the onboarding or not.

f:id:epishie:20201223163245p:plain

Since onboarding is a one-off event, it requires deleting the app storage (SharedPreferences) to re-test and debug. With the Shared Preferences Viewer, instead of clearing the app storage, the actual key-values can be verified and modified while running the app to modify the behavior.

These are just 3 of the core plugins that we use during development. There are other plugins like the Databases plugin for inspecting local SQLite databases, and Images plugin for monitoring image loading using third-party libraries like Fresco.

Hyperion

Hyperion is a plug-and-play debug menu library designed to help fill in the gaps between design, development, and QA. By adding the library as a dependency to the Android project, usually for debug configuration only, the menu can be accessed while running the app by shaking the device or through the notification drawer.

f:id:epishie:20201223163307g:plain:w320

Hyperion includes a variety of core plugins each serving a different function. Like Flipper, Hyperion also supports custom plugins developers can add to support different use-cases.

Over the years, we've created our own custom Hyperion plugins to assist in debugging and verifying the app behavior.

Drawer Other Tools
f:id:epishie:20201223163435p:plainf:id:epishie:20201223163453p:plain

During the renewal project, some tools were created as needed to specifically help debug the new features that we were adding to the app.

Button Style Verification

Before starting to work on the renewal project, to be able to use the new Material Design components, we had to migrate from the AppCompat theme to the MaterialComponents theme. Since the project already had a lot of custom button styles declared used within the app, we were afraid existing UI design might break when changing the theme.

f:id:epishie:20201223163547g:plain:w320

Instead of checking each screen for each button style, we built a simple tool where designers can see a preview of all the button styles in a single screen. This is a very simple tool but it definitely cut the time that it takes between designers' feedback and bug-fixing.

Ken Burns Preview

f:id:epishie:20201223163618g:plain

In some of the the screens that we're added during the renewal project, series of images are shown in a slideshow with fade-in/fade-out transitions and custom animations which are called Ken Burns Effect.

The effect patterns are designed to be random, and depend on the number of images to be shown and whether there's a video included. Since this feature is already implemented in out Cookpad iOS app, the actual tweaking of the effect parameters were already done as discussed in detail in this post.

f:id:epishie:20201223163753g:plain:w320

However, designers still need to verify that the effects are played correctly and are the same as the iOS version. Testing on the actual screen is unreliable since the effects are dependent on randomness and the count and type of content. To help designers verify the feature quickly, we built this tool where they can check the effects, change the patterns and verify the effect with different combination of contents.

Quick Navigation

f:id:epishie:20201223163850p:plain:w320

During the renewal project, we were adding new features that has a set of list and details screens. Most of the time, these screens are implemented independently and simultaneously and sometimes by different developers. Because of the parent-child relationship of the screens, the actual navigation between the screens cannot be implemented until the set is completed. To allow such navigation during development, we built a simple navigation list screen so that it's possible to access the child screens.

Log Viewer

As with most apps these days, in the Cookpad Android app, we do record logs of user's actions which are useful in analyzing and understanding the status of our services. Since these logs are buffered before being sent to our log infrastructure, when adding logs to the app, to make sure that the implementation is correct, we had to wait for the logs to be printed in logcat and/or check the backend.

f:id:epishie:20201223163909p:plain:w320

To speed-up the development and debugging, we've added a simple Log Viewer tool. Since our logging library called Puree allows adding filters that can be applied to each log before sending, we created and added a filter where we can record the logs to a local database which the Log Viewer can query and display as a list.

Demo Apps

f:id:epishie:20201223163938p:plain:w320

The Cookpad Android project was split into feature modules for better code organization and cohesion, and to potentially improve the time it takes to build and run the app. However, even if the features are independent of each other, debugging and verification still involves the whole project and the whole app has to be built and run.

The Cookpad iOS app has the Sandbox apps which are mini-apps that contain a single feature that depends only on a subset of the app's modules. For Android we built a similar mechanism called Demo apps.

f:id:epishie:20201223164015g:plain:w320

With Demo apps, it's possible to build only the modules needed for a feature and provide a simple entry point for the screens a feature has instead of building and running the whole app. The details of how Demo apps are implemented in Android are described in this post.

Final thoughts

We all know that developing large projects like the Android renewal project is difficult. Debugging and testing of projects with large and complex feature set are even harder. However, in most situations, there are tools already available to address some of the pain points we encounter during development. In situations in which there's no tool yet, why not try creating one. If you do, you might want to share it so that everyone can use it too.

Compositional LayoutとDiffable Data Sourceを使ってiOSアプリのつくれぽ詳細画面を実装する

$
0
0

クックパッドの事業開発部でiOSエンジニアをしている角田(id:muchan611)です。普段はクックパッドiOSアプリの検索に関する機能を開発しています。

クックパッドの基本的な機能のひとつである「つくれぽ」を表示する「つくれぽ詳細画面」を、UICollectionViewCompositionalLayoutUICollectionViewDiffableDataSourceを使って実装したので、その過程や実装方針についてご紹介します。

背景

つくれぽとは、クックパッドのレシピを見て料理をした人が、その料理を他の人におすすめするために投稿するもので、検索ユーザーはつくれぽ通してレシピを探せるようになっています。

事業開発部では「つくれぽからレシピを決める」体験を増やす取組みを行っていますが、各施策の方針を決定するために、多くのユーザーインタビュー(※)や数値分析を実施し判断材料を得ています。
そのインタビューの中で「レシピを決定するには材料情報が必要だが、つくれぽ詳細画面にはそれが表示されておらず、レシピ決定の障壁になっている可能性がある」という課題が明らかとなり、つくれぽ詳細画面に材料を表示する施策が決まりました。

今回の開発では、これまでの実装を拡張するのではなく、CollectionViewを用いて画面を作り替えることとなったため、その際に得た知見や実装方針について、ひとつの例としてご紹介できればと思います。

課題と実装方針

実はiOSクックパッドでは、2020年の春に大きなリニューアルを実施し、その際につくれぽ詳細画面を大きく変更しました。
ただ、この時に実装されたつくれぽ詳細画面では、コンテンツが追加されることを想定していなかったため、スクロールができない画面となっていました。変更前後のつくれぽ詳細画面は以下のような見た目で、以前はViewControllerの上に直接各パーツが配置されていました。

以前のつくれぽ詳細画面

f:id:muchan611:20201223222235p:plain:w160

新しいつくれぽ詳細画面

f:id:muchan611:20201223222420p:plain:w160f:id:muchan611:20201223222452p:plain:w160

そして、今回材料コンテンツを実装するにあたって、以下の問題をクリアする必要がありました。

  • スクロールしないことを前提にした制約が多く、そのまま構造を変えずに実装を進めると、非常に複雑でメンテナンスしにくい状態になりかねない
  • 今後、材料以外にもレシピ決定に必要なコンテンツを追加していく可能性が高く、継続的にコンテンツを増やせるような構造にする必要がある

このような背景を踏まえて今後の継続的な開発を検討した結果、 UICollectionViewで画面を作り替えUICollectionViewCompositionalLayoutUICollectionViewDiffableDataSourceを利用する方針で開発を進めることにしました。主な理由は以下の通りです。

  • コンテンツの追加が容易に行える
    • 前述した通り、今後もレシピ決定に必要なコンテンツを追加する可能性があり、レイアウトの変更に強くシンプルで分かりやすい実装が実現できるCollectionViewが最適だった
  • UICollectionViewCompositionalLayoutを利用することで、section毎のカラム数指定や各コンテンツのサイズ指定が柔軟で容易になる
    • 例えば、材料sectionは2カラム、それ以外は1カラムで表示するといった、文字数によるコンテンツの高さ計算を自前で行う必要がなく、それらの調整をAutoLayoutに任せることが可能
  • UICollectionViewDiffableDataSourceを利用することで、データへのアクセスも容易で安全になる
    • 表示データをインスタンス変数に保持して利用するケースと比較すると、UICollectionViewDiffableDataSourceを利用することでデータの保持をフレームワーク側に任せることができ実装が簡素化できる
    • 型による制約が強いため、データとUIの不整合を防止できる

実装内容

全てのコードを載せると全体が分かりにくくなってしまうため、一部割愛しながら実装内容についてご紹介します。

DataSourceの定義

まずdataSourceですが、以下のような定義になっています。

vardataSource:UICollectionViewDiffableDataSource<Section, Item>!

SectionIdentifierTypeにはSectionを、ItemIdentifierTypeにはItemというenumを指定しています。 それぞれのenumの定義は以下の通りです。(TsukurepoViewItemは、APIから取得したつくれぽ情報をViewにとって都合の良い形に変換した構造体です)

enumSection:CaseIterable {
    case media
    case margin
    case recipeTitle
    case recipeDescription
    case ingredientsHeader
    case ingredients
    case showMore
}

enumItem:Hashable {
    case media(media:TsukurepoViewItem.Media?, tsukurepo:TsukurepoViewItem.Tsukurepo?)
    case margin
    case recipeTitle(TsukurepoViewItem.RecipeOverview?)
    case recipeDescription(String)
    case ingredientsHeader
    case ingredients(TsukurepoViewItem.Ingredients)
    case showMore
}

このように分けた背景についてですが、まず、UICollectionViewCompositionalLayoutでは、section毎にレイアウトを組む仕組みになっているため、Sectionはレイアウト単位で分けることにしました。

そして、Itemはcell単位で分けており、cellに渡したいデータをenumのassociated valueで持つようにしています。 UICollectionViewDiffableDataSourceの初期化時に指定するcellProvider内で、各cellの更新処理を実装するため、その際に必要なデータへ簡単にアクセスできるようにするためです。

dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView:collectionView) { [weak self] (collectionView:UICollectionView, indexPath:IndexPath, identifier:Item) ->UICollectionViewCell? inguardletself=selfelse { returnnil }
    switch identifier {
    caselet .media(media, tsukurepo):letcell= collectionView.dequeue(TsukurepoDetailsMediaCell.self, for:indexPath)
        cell.configure(media:media, tsukurepo:tsukurepo)
        cell.delegate =selfreturn cell
        //..以下省略..
    }
}

dataSourceへsnapshotをapplyする処理は、下記のapply(tsukurepo: TsukurepoViewItem?)内で実装しており、この関数はviewDidLoad()内やつくれぽ情報の取得が完了した際に呼びだされます。

overridefuncviewDidLoad() {
  super.viewDidLoad()
  //..途中省略..

  apply(tsukurepo:nil)

  presenter.tsukurepo
    .drive(onNext: { [weak self] tsukurepo inself?.apply(tsukurepo:tsukurepo)
    })
    .disposed(by:disposeBag)
}

viewDidLoad()が呼び出された時点では、まだつくれぽ情報を取得していないので、引数のtsukurepoがnilとなります。その場合は、mediamarginrecipeTitleItemIdentifierTypeのみを追加し、それぞれのcellではempty viewを表示するように実装しています。
つくれぽ情報取得後は全てのsectionにItemIdentifierTypeを追加し、材料については存在する材料の数だけingredientsを追加します。

funcapply(tsukurepo:TsukurepoViewItem?) {
    varsnapshot= NSDiffableDataSourceSnapshot<Section, Item>()
    snapshot.appendSections(Section.allCases)

    snapshot.appendItems([.media(media:tsukurepo?.media, tsukurepo:tsukurepo?.tsukurepo)], toSection: .media)
    snapshot.appendItems([.margin], toSection: .margin)
    snapshot.appendItems([.recipeTitle(tsukurepo?.recipeOverview)], toSection: .recipeTitle)
    iflettsukurepo= tsukurepo {
        ifletdescription= tsukurepo.recipeOverview.description {
            snapshot.appendItems([.recipeDescription(description)], toSection: .recipeDescription)
        }
        snapshot.appendItems([.ingredientsHeader], toSection: .ingredientsHeader)
        letingredients:[Item]= tsukurepo.ingredients.map { .ingredients($0) }
        snapshot.appendItems(ingredients, toSection: .ingredients)
        snapshot.appendItems([.showMore], toSection: .showMore)
    }

    dataSource.apply(snapshot, animatingDifferences:false)
}

レイアウトの生成

つくれぽ詳細画面の構造を簡略化するとこのようになります。(2枚目はスクロール後です)

f:id:muchan611:20201223222700p:plain:w300f:id:muchan611:20201223222719p:plain:w300

これを実現しているコードは下記の通りですが、section毎にコンテンツの高さを割合や絶対値、推定値で指定しています。
例えば、mediaはつくれぽ画像を含むsectionで、仕様上縦横比が3:4になるように表示したいのですが、この場合はgroupのサイズに次のような指定をします。

letgroupSize= NSCollectionLayoutize(widthDimension: .fractionalWidth(1.0),
                                       heightDimension: .fractionalWidth(1.33))
letgroup= NSCollectionLayoutGroup.horizontal(Layoutize:groupSize, subitem:item, count:1)

.fractionalWidth.fractionalHeightを指定することで、幅や高さに対する割合でコンテンツのサイズを決めることができるためです。また、説明文や材料などは文字数によって高さを可変にしたり、文字サイズ変更の際に適切な高さを適用したりするため、.estimatedを指定しています。そうすることで、コンテンツサイズが変更される時にシステム側で実際の値を計算し調整してくれます。また、最下部に表示する「このレシピを詳しく見る」ボタンの高さは固定にしたいため、絶対値で指定ができる.absoluteを利用しています。
これらのDimensionについては公式ドキュメントに詳細が記載されています。

letlayout= UICollectionViewCompositionalLayout { [weak self] (sectionIndex:Int, _:NSCollectionLayoutEnvironment) ->NSCollectionLayoutection? inguardletself=selfelse { returnnil }
    letsectionKind=self.dataSource.snapshot().sectionIdentifiers[sectionIndex]

    letitemHeight:NSCollectionLayoutDimensionletgroupHeight:NSCollectionLayoutDimensionswitch sectionKind {
    case .media:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .fractionalWidth(1.33)
    case .margin:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .fractionalHeight(0.03)
    case .recipeTitle:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .fractionalHeight(0.15)
    case .recipeDescription:letheight= NSCollectionLayoutDimension.estimated(72)
        itemHeight = height
        groupHeight = height
    case .ingredientsHeader:letheight= NSCollectionLayoutDimension.estimated(40)
        itemHeight = height
        groupHeight = height
    case .ingredients:letheight= NSCollectionLayoutDimension.estimated(35)
        itemHeight = height
        groupHeight = height
    case .showMore:
        itemHeight = .fractionalHeight(1.0)
        groupHeight = .absolute(108)
    }

    letitemSize= NSCollectionLayoutize(widthDimension: .fractionalWidth(1.0),
                                          heightDimension:itemHeight)
    letitem= NSCollectionLayoutItem(Layoutize:itemSize)
    letgroupSize= NSCollectionLayoutize(widthDimension: .fractionalWidth(1.0),
                                           heightDimension:groupHeight)
    letgroup= NSCollectionLayoutGroup.horizontal(Layoutize:groupSize, subitem:item, count:sectionKind.columnCount)

    return NSCollectionLayoutection(group:group)
}

そして、材料のsectionでは1行に2つのitemを表示したいため、countを指定することでsectionによって表示するitemの数を変えています。
sectionKind.columnCountは、材料sectionの場合に2、それ以外は1を返します。

letgroup= NSCollectionLayoutGroup.horizontal(Layoutize:groupSize, subitem:item, count:sectionKind.columnCount)

このようにUICollectionViewCompositionalLayoutを使う事で、カラム数を変えたりコンテンツサイズを柔軟に指定したりすることができ、複雑なレイアウトもシンプルで簡単に実現することができます。

iOS12以下のサポートについて

UICollectionViewCompositionalLayoutUICollectionViewDiffableDataSourceはiOS12以下で利用できないため、iOS12以下で同じような実装を実現したい場合はIBPCollectionViewCompositionalLayoutDiffableDataSourcesなどのバックポートライブラリを使用する必要があります。

クックパッドでも、主要な画面においては、iOS12で表示できるようにこれらのバックポートライブラリを利用するケースがありました。しかし、公式の仕組みとの挙動の違いから少なからずサポートコストがかかっていたため、今回はiOS13以上の端末でのみ新しいつくれぽ詳細画面を表示しiOS12以下をサポートしない、という事業判断を行いました。
(本実装を行った2020年11月時点において、クックパッドアプリではiOS12をサポートしていましたが、現在はサポート対象をiOS13.1以上に引き上げています)

まとめ

ここまでに述べたように、UICollectionViewCompositionalLayoutを用いることでsection毎のカラム数指定や各コンテンツのサイズ指定を柔軟で容易に行えるため、レイアウトの実装がシンプルかつ比較的簡単になります。また、UICollectionViewDiffableDataSourceを利用する事で、データの保持をフレームワーク側に任せることができ実装が簡素化できるほか、データとUIの不整合の防止にも繋がるため、より安全な実装が実現できます。
そして、これらの仕組みを利用してつくれぽ詳細画面を作り替えることで、新しいコンテンツの追加が容易となり、スムーズに追加開発を進められる状況になっています。

施策の結果については、(レシピ決定のひとつの指標である、つくれぽ詳細から遷移したレシピ画面での)クリップ率上昇やつくれぽ一覧画面の3日以内再訪率が上昇したことが分かり、「つくれぽからレシピを決める」体験を増やすことができたと評価しています。

このように、クックパッドではユーザーインタビューや数値分析を通して施策を考え開発を進めており、一緒にサービス開発を盛り上げてくれるiOSエンジニアを大募集しております!!
カジュアル面談なども実施しておりますので、少しでもご興味がある方はぜひお気軽にお問い合わせください!

https://info.cookpad.com/careers/


※現在、ユーザーインタビューはオンラインで実施しています

プロと読み解く Ruby 3.0 NEWS

$
0
0

技術部の笹田(ko1)と遠藤(mame)です。クックパッドで Ruby (MRI: Matz Ruby Implementation、いわゆる ruby コマンド) の開発をしています。お金をもらって Ruby を開発しているのでプロの Ruby コミッタです。

本日 12/25 に、ついに Ruby 3.0.0 がリリースされました。一昨年、昨年に続き、今年も Ruby 3.0 の NEWS.md ファイルの解説をします。NEWS ファイルとは何か、は一昨年の記事を見てください(なお Ruby 3.0.0 から、NEWS.md にファイル名を変えました)。

Ruby 3.0 は、Ruby にとってほぼ 8 年ぶりのメジャーバージョンアップとなります(Ruby 2.0 は 2013/02/24)。高速化(Ruby 3x3)、静的型解析、並列並行の3大目標をかかげて開発されてきた記念すべきバージョンですが、NEWS.mdはわりと淡々と書かれているので、この記事も淡々と書いていきます。

他にも Ruby 3.0 を解説している記事はいくつかあります。見つけたものだけリンクを置いておきます。

なお、本記事は新機能を解説することもさることながら、変更が入った背景や苦労などの裏話も記憶の範囲で書いているところが特徴です。

■言語の変更

キーワード引数の分離

  • Keyword arguments are now separated from positional arguments. Code that resulted in deprecation warnings in Ruby 2.7 will now result in ArgumentError or different behavior. [Feature #14183]

Ruby 3では、キーワード引数が通常の引数とは独立した引数になりました。これは非互換な変更になっています。

# キーワード引数を受け取るメソッドdeffoo(key: 42)
end

foo(key: 42)      # OK: キーワード引数を渡している

opt = { key: 42 }
foo(opt)          # NG: 普通の引数を渡しているのでエラー(2.7では警告付きで動いていた)

foo(**opt)        # OK: ハッシュを明示的にキーワードに変換している

2.7では普通の引数をキーワード引数に暗黙的に変換していましたが、3.0からはこの暗黙的変換を行わないようになりました。多くのケースは上記の例のように、foo(opt)foo(**opt)のように書き換える、で対応できると思います。

なお、キーワード引数から普通の引数への暗黙的変換は維持されています(削除するには互換性の影響が大きすぎたため)。次のコードはRuby 3.0でも動作します。

# 普通のオプショナル引数を受け取るメソッドdeffoo(opt = {})
end

foo(key: 42) # OK: キーワード引数が暗黙的に普通の引数に変換される# # ↑は動きますが、今後は次のように書くのがおすすめです# def foo(**opt)# end

この変更についての詳細は、昨年の『プロと読み解くRuby 2.7 NEWS』や、Ruby公式サイトの移行ガイドを参照してください。

裏話

これの裏話を語りだすととても長いので、かいつまんで。

昨年の記事でも書いたことですが、Ruby 2.0でキーワード引数を最初に実装したのは私(遠藤)です。当時はRuby 1.8との連続性・互換性を意識しすぎたため、やや無理のある言語設計となっていました。そのため、非直感的挙動が頻繁に報告される(しかも本質的に壊れているので場当たり的な対応しかできない)という設計不良になっていました。これをどうにかすることは、Ruby設計者のmatzだけでなく、自分にとっても積年の悲願でした *1

とはいえ、多くのケースではそれなりに期待通りに動いてきた機能なので、2.7で変更を予告する警告を導入したところ、数多くの悲鳴や不満の声があがりました。変更の延期や中止も視野に入れつつ、Ruby on Railsの交流サイトにmatzがスレッドを立てて、ユーザの声を直接聞くことにしました。延べ40件ほどのさまざまなコメントをいただいたので、遠藤がすべてのご意見を何度も読み返し、分類集計しました。その結果、「変更予告の警告が出ること自体が不満 *2」「実務的な対応ノウハウが共有されていない *3」ということが不満の源泉で、問題の変更自体には意外と前向きな人が多いことがわかりました。そこで、前者の問題に対しては最善の対応ということで 2.7.2 でデフォルトで警告を無効にしました。後者の問題に対しては、コメント内で上げられた個別の問題に対して対処方法を一緒に考えていきました。また、警告を柔軟に非表示にできるdeprecation_toolkit gemがスレッド内で共有されたことも大きかったです。一方でRuby on Rails本体は(kamipoさんというすごい人やamatsudaさんなどのご尽力で)キーワード引数の分離に成功しました。分離を延期させるとRuby on Railsのリリーススケジュールに悪影響になる可能性がある *4ということもヒアリングでわかったので、熟考に熟考を重ねた上で、3.0で変更を決行することになりました。

(文責:mame)

deprecated警告がデフォルトで出ないことになった

  • Deprecation warnings are no longer shown by default (since Ruby 2.7.2). Turn them on with -W:deprecated (or with -w to show other warnings too). Feature #16345

「廃止予定である」という警告は原則として$VERBOSEモードでしか表示されないことになりました。キーワード引数分離の警告だけではなく、すべてのdeprecated警告が対象です。3.0.0 からではなく、2.7.2 も変更されています。

前節で延べたように、キーワード引数分離の経験がきっかけで、deprecated警告のありかたが見直されたためです。昔は原則として、「まず$VERBOSEモードでだけ警告を出す」「次に無条件で警告を出す」「最後に変更する」という3バージョンを経て廃止を行っていました。しかしこれは変更までに時間がかかるわりに、無条件警告のフェーズはエンドユーザ(Rubyで書かれたプログラムを使うだけのユーザ)に見せても詮無い警告を見せるだけになるのでかえって不便、というフィードバックを多数得たので、無条件警告フェーズをなくすということになりました。

(mame)

引数委譲の記法の拡張

  • Arguments forwarding (...) now supports leading arguments. [Feature #16378]

キーワード引数の分離の悪影響の1つに、引数を委譲するのがめんどうになることがあります。そのため、Ruby 2.7では引数を委譲するための構文が導入されたのですが、引数を一切変更できないので使えるケースが限定されていました。

Ruby 3.0では、次のように、先頭の引数を取り除いたり、新しい値を追加したりすることが許されるようになりました。

defmethod_missing(meth, ...)
  send(:"do_#{meth}", ...)
end

先頭の引数以外はやはり変更できないのですが、これだけでも多くのケースが救われるという声が前述のヒアリングスレッドなどでも聞かれたため、導入されました。

(mame)

ブロックがキーワード引数を受け取る場合の意味の整理

  • Procs accepting a single rest argument and keywords are no longer subject to autosplatting. This now matches the behavior of Procs accepting a single rest argument and no keywords. [Feature #16166]

あまり知られていないかもしれませんが、Rubyのブロックの引数は伏魔殿です。

proc {|a, b|    a }.call([1]) #=> 1proc {|a, k:42| a }.call([1]) #=> 1

上記のように、2引数以上を受け取るブロックに配列をひとつだけ渡して呼び出すと、配列の中身が引数として解釈されます。なので、上記の例ではa[1]ではなく1が入ります。この挙動はautosplatなどと呼ばれることもあります(正式な機能名なのかは知らない)。

1引数のブロックではautosplatはされません。

proc {|a| a }.call([1]) #=> [1]

また、可変長引数を受け取るブロックでもautosplatはされません。

proc {|*a| a }.call([1]) #=> [[1]]

ただし、普通の引数に加えて可変長引数を受け取るブロックではautosplatがされます。

proc {|x, *a| a }.call([1]) #=> []  # xに1が入り、可変長引数のaは空配列になる

正直、autosplatの条件は遠藤も正確に理解していません(コードを読んでも理解できません)。非常にややこしい挙動ですが、多くの場合でうまく動くので、熟考に熟考を重ねた上でなんとなくこうなっています。

さて今回の変更は、可変長引数とキーワード引数を組み合わせた場合の話です。2.7まではautosplatがされていましたが、3.0からはautosplatがされないことになりました。

proc {|*a, k:42| p a }.call([1]) #=> [1]    # 2.7proc {|*a, k:42| p a }.call([1]) #=> [[1]]  # 3.0

難しいですね……。

(mame)

$SAFE削除

  • $SAFE is now a normal global variable with no special behavior. C-API methods related to $SAFE have been removed. [Feature #16131]

古のセキュリティ機構である $SAFE機能は、Ruby 2.7 で廃止されましたが(プロと読み解くRuby 2.7 NEWS - クックパッド開発者ブログ$SAFEの廃止」)、まだ対応していないコードに警告などを出すため、$SAFE自体を特別扱いして、何か代入されたら警告を出す、もしくは例外を出す、という挙動になっていました。このような特別使いを Ruby 3.0 からやめて、本当にただのグローバル変数になった、ということです。

$SAFE = 42# Ruby 2.7 までは、エラー(0 or 1 しか許さなかった)、Ruby 3.0 からは素通し
p $SAFE#=> 42

(ko1)

$KCODE削除

  • $KCODE is now a normal global variable with no special behavior. No warnings are emitted by access/assignment to it, and the assigned value will be returned. [Feature #17136]

$SAFEと同じような話ですが、Ruby 1.9.0(ずいぶんと古いですね)から値を設定しても何も意味がなかった$KCODEについて、値を代入したり参照したりすると警告をだしていたのを、Ruby 3.0 からは特別扱いしないようにしました。

$KCODE = 42
p $KCODE#=> Ruby 2.7 以前# warning: variable $KCODE is no longer effective; ignored# warning: variable $KCODE is no longer effective# nil##=> Ruby 3.0# 42

(ko1)

シングルトンクラス定義の中での yieldが禁止に

  • yield in singleton class definitions in methods is now a SyntaxError instead of a warning. yield in a class definition outside of a method is now a SyntaxError instead of a LocalJumpError. [Feature #15575]

次のようなコードがエラー(LocalJumpError)になるようになりました。

deffooclass<< Object.new
    yieldendend

foo{ p :ok } #=> :ok

Ruby 2.7で廃止予定となり(プロと読み解くRuby 2.7 NEWS - クックパッド開発者ブログ「シングルトンクラスの中で yield は廃止予定」 )、順当に廃止された、という感じです。

(ko1)

パターンマッチが正式機能に

Ruby 2.7で試験的に導入されたパターンマッチですが、正式な機能となりました。

具体的な変更としては、パターンマッチを使うと出ていた警告が 3.0 では出なくなりました。

case [1, 2, 3]
in [x, y, z]
end# Ruby 2.7 では警告が出ていた(3.0 では出ない)#=> warning: Pattern matching is experimental, and the behavior may change in future versions of Ruby!

(mame)

右代入が導入された

  • One-line pattern matching is redesigned. [EXPERIMENTAL]
    • => is added. It can be used as like rightward assignment. [Feature #17260]

一部で待望の機能とされている、右代入が導入されました。

{ a: 1, b: 2, c: 3 } => hash

p hash #=> [1, 2, 3]

さて、これはパターンマッチの一部と言うことになっています。よって、右側には任意のパターンが書けます。ただし下記の通り、experimentalであるという警告が出ます(パターンが単一の変数のときだけは導入が確定的なので、experimental警告は出ません)。

{ a: 1, b: 2, c: 3 } => { a:, b:, c: }
# warning: One-line pattern matching is experimental, and the behavior may change in future versions of Ruby!

p a #=> 1
p b #=> 2
p c #=> 3

{ a: 1, b: 2, c: 3 } => { a:, b:, c:, d: }  # NoMatchingPatternError(キーワード `d` がないため)

裏話

自分は右代入の使いどころがよくわかっていないのですが、複数行に渡るメソッドチェーンの最後に代入するときなどに便利という人が何人かいる(matzを含む)ので導入されたようです。正直、無理に使う必要はないと思います。

いくつか注意点だけ書いておきます。

パターンマッチの一部として実現されているため、インスタンス変数などに右代入することはできません(インスタンス変数はパターンとして書けないので)。

{ a: 1, b: 2, c: 3 } => @a# SyntaxError

また、普通の代入と違って、返り値は利用できません。

ret = ({ a: 1, b: 2, c: 3 } => hash) #=> SyntaxError (void value expression)

さらに、うっかり引数に右代入を書こうとすると、キーワード引数になってしまうので注意です。

foo(val = expr)   # OK
foo(expr => val)  # NG: expr をキー、val を値とするキーワード引数

(mame)

一行パターンマッチが再設計された

前項の右代入は、Ruby 2.7では=>ではなくinという演算子で導入されていたものです。しかし、思ったほど使われなさそうということで、より右代入らしい記法で再試験導入することになりました。

そしてin演算子自体は、マッチの成否をtrue/falseを返すものに変わりました。

{ a: 1, b: 2, c: 3 } in { a:, b:, c: }     #=> true
{ a: 1, b: 2, c: 3 } in { a:, b:, c:, d: } #=> false# warning: One-line pattern matching is experimental, and the behavior may change in future versions of Ruby!

Ruby 2.7に引き続き、experimental警告が出ます。

なお、in=>は返り値以外は同じです。

(mame)

findパターンが追加された

配列の中でマッチする箇所を探索するパターンが試験導入されました。

case ["a", 1, "b", "c", 2, "d", "e", "f", 3]
in [*pre, String => x, String => y, *post]
  p pre  #=> ["a", 1]
  p x    #=> "b"
  p y    #=> "c"
  p post #=> [2, "d", "e", "f", 3]end

ちょっとややこしいですが、[*pre, String => x, String => y, *post]というパターンは、Stringが2連続で登場する箇所を探すパターンです。上記の例では、"b", "c"の箇所にマッチしています(最初にマッチしたところで探索は止まります)。

裏話

matzの肝いりの新機能です。ユースケースがあまり明確ではないのですが、matzの一声で入りました。

探索を行うパターンは、あまり一般的なパターンマッチにはない機能ですが、線形探索しか行わないようになっているので、そこまで複雑な挙動にはならないと思います。

(mame)

スーパークラスでクラス変数を再定義してしまったとき、サブクラスで参照したときに例外が出るようになった

  • When a class variable is overtaken by the same definition in an ancestor class/module, a RuntimeError is now raised (previously, it only issued a warning in verbose mode). Additionally, accessing a class variable from the toplevel scope is now a RuntimeError. [Bug #14541]

実は継承が絡むと難しいクラス変数ですが、わかりづらい例で例外が出るようになりました。

まず、例外が出ないケースをご紹介します。

classC@@foo = :CendclassD< C@@foo = :D# C の @@foo を変更しているendclassC
  p @@foo#=> :Dend

このとき、@@fooというのは、Dでも定義しているように見えて、実はCのクラス変数を変更しています。継承元を見るわけですね。ぱっと見た感じわかりづらい。

さて、C@@fooがあったときは、Dの文脈でクラス変数を設定する、ということはわかりました。では、先にDに設定したあと、その基底クラスであるCのクラス変数を設定したらどうなるでしょうか。

classCendclassD< C@@foo = :D# D の @@foo に代入endclassC@@foo = :C# C の @@foo に代入endclassD
  p @@foo# Ruby 2.7 以前#=> warning: class variable @@foo of D is overtaken by C#=> :C## Ruby 3.0#=> class variable @@foo of D is overtaken by C (RuntimeError)end

Ruby 2.7までは、Cに上書きされてしまったぞ、というような警告を出して、C@@fooが参照されるようになりました(そのため、警告の後、:Cが返る)。しかし、ちょっとわかりづら過ぎるだろう、ということで、警告の代わりにエラーが出るようになりました(RuntimeError)。

それからついでに(?)、トップレベルでクラス変数を設定したり参照したりすることが禁止(RuntimeError)されました。

# 設定も禁止
@@foo = 1
#=> class variable access from toplevel (RuntimeError)

class Object
  @@bar = 2
end

# 参照も禁止
p @@bar
#=> class variable access from toplevel (RuntimeError)

正直よくわかんないんで、クラス変数はなるべく使わない方がいいと思いますねぇ(とくに、継承を絡めて)。Ractorで使えないし。

(ko1)

Numbered parameter への代入が警告から禁止に

  • Assigning to a numbered parameter is now a SyntaxError instead of a warning.

1.times{|i| p i}の代わりに 1.times{p _1}のように、ブロック仮引数の名前を暗黙の引数名で書けるというNumbered parameterという機能が Ruby 2.7 から導入されました(プロと読み解くRuby 2.7 NEWS - クックパッド開発者ブログ「Numbered parameters」)。

_1などを特別扱いするにあたって、既存のコードで_1などの名前を利用している例について議論があったのですが、Ruby 2.7の段階では「まぁ、そんなに使ってないだろうから、警告だけ出しとこ」、となりました。Ruby 3.0 では、利用している箇所を全部エラーにするようにしました。

_1 = 10# ローカル変数名として利用defa _1  # 仮引数名として利用enddef_1# メソッド名として利用end1.times{|_1| p _1} # ブロックの仮引数名として利用

この例では、

  • Ruby 2.6 までは、問題なく利用可能
  • Ruby 2.7 では、パース時にそれぞれ "warning: `_1' is reserved for numbered parameter; consider another name"という警告
  • Ruby 3.0 では、パース時にそれぞれ "_1 is reserved for numbered parameter"というエラーメッセージで構文解析失敗

となります。最後の例は、意味が変わらないので通ってもよさそうですが、まぁ自分で変数名として使う分には一律禁止になりました。

(ko1)

一行メソッド定義が追加された

endのないメソッド定義の新文法が導入されました。

defsquare(x) = x * x

p square(5) #=> 25

次のように書くのとまったく同じ意味です。

defsquare(x)
  x * x
end

こんな単純なメソッドのために3行も書かなくて良くなりました。嬉しいですよね??

無引数のメソッドも素直に書けますが、=の前にスペースが必須です。

defanswer = 42# OKdef answer=42   # NG: SyntaxError

なぜなら、setterメソッドとの区別ができないためです。また、setterメソッドは見た目がややこしくなることもあり、一行メソッド定義では書けなくなっています。

defset=(x) = @x = x
#=> setter method cannot be defined in an endless method definition

裏話

もともと私(遠藤)が提案した機能です。「Rubyの文法はendを多用するので、Ruby が終わりそうで縁起が悪い」というエイプリルフールネタでした。

しかしmatzはエイプリルフールネタということを理解した上で「細かい点を除けば真面目にポジティブ」といい、nobuが細かい問題を解決した *5ので、入ってしまいました。

真面目な話、上記の squareメソッドのように簡単なメソッド定義で 3 行も書くのは無駄なような気はしていました。Rubyのパッケージに含まれているコードで調べると、なんと 24% のメソッド定義が 3 行であることがわかりました。まあ、それでも新文法を導入するのは躊躇しそうなものですが、「一部のプログラムで便利な可能性がありそう」というmatzの直感により導入されました。

Rubyの新機能提案ではユースケースを強く求められますが、matzだけは例外です。直感に基づく決断は、言語仕様を委員会制で決める言語ではできないと思うので、面白いなあと思っています。

なお、一行メソッド定義は十分シンプルな場合に使われることを想定しているので、副作用を伴う式などは書かないほうがよいです。setterメソッドが定義できなくなっているのには、そういう理由もあります。

(mame)

式展開を含む文字列リテラルは、frozen-string-literal: trueで freeze しなくなった

  • Interpolated String literals are no longer frozen when # frozen-string-literal: true is used. [Feature #17104]

# frozen-string-literal: trueを指定しておくと、その後にくる文字列リテラルがすべて frozen な状態となります。

# frozen-string-literal: true

p "foo".frozen? #=> true

これは、式展開を含む文字列リテラル(埋め込み文字列)も frozen にしていました。

# frozen-string-literal: true

p "foo#{42}bar".frozen? #=> true

Ruby 3.0からは、埋め込み文字列については freeze せんでもいいだろ、ってことで freeze されなくなりました(この例では falseが出力される)。

frozen-string-literal: true自体は、最初から freeze しておくことで何度も同じ文字列を生成しなくても済む、ということを意図していたけれど、埋め込み文字列はそういうわけにもいかないので、毎回生成しています。つまり、この利点はないのにわざわざ freeze しなくてもいいだろう、という提案です。

私の記憶が確かなら、この埋め込み文字列の挙動は、埋め込み文字列でも文字列リテラルの一種なので、文字列リテラルが frozen である、という指定なら、埋め込み文字列も freeze しちゃったほうが理解はしやすいよね、という意図で freeze していたと思うのですが、Matz が、まぁ freeze せんでもいいよね、って言ったので freeze しないようになりました。

個人的には、freeze したままのほうが良かったなぁ。

(ko1)

静的解析基盤が導入された

  • A static analysis foundation is introduced. See "Static analysis" section in detail.
    • RBS is introduced. It is a type definition language for Ruby programs.
    • TypeProf is experimentally bundled. It is a type analysis tool for Ruby programs.

RBSとTypeProfが導入されました。この辺はすでに別記事を書いているのでご参照ください。

techlife.cookpad.com

techlife.cookpad.com

(mame)

コマンドラインオプション

--helpとページャ

  • When the environment variable RUBY_PAGER or PAGER is present and has non-empty value, and the standard input and output are tty, --help option shows the help message via the pager designated by the value. [Feature #16754]

細かい話です。環境変数 RUBY_PAGERPAGERが空でない文字列で設定されていれば、ruby --helpという詳細ヘルプを出力するとき、それをページャーとして利用して出力するようになりました。最近、git とかでも見る挙動ですね(git では環境変数が設定されてなくても lessを起動しちゃうけど)。

関係ないけど、ruby -hで簡易版ヘルプ、ruby --helpで詳細版ヘルプが出ます。

(ko1)

--backtrace-limitオプション

  • --backtrace-limit option limits the maximum length of backtrace. [Feature #8661]

例外発生時のバックトレースの最大行数を指定するオプションが導入されました。

deff6 = raisedeff5 = f6
deff4 = f5
deff3 = f4
deff2 = f3
deff1 = f2
f1

みたいなコードを次のように実行すると ... 3 levels...のように省略されます。

$ ruby --backtrace-limit=3 test.rb
-e:6:in `f6': unhandled exception
        from -e:5:in `f5'
        from -e:4:in `f4'
        from -e:3:in `f3'
         ... 3 levels...

これは、後述するバックトレースの再逆転に際して導入されました。

(mame)

■組み込みクラスのアップデート

Arrayのサブクラスのメソッドが、サブクラスではなく、Arrayクラスのオブジェクトを返すようになった

  • The following methods now return Array instances instead of subclass instances when called on subclass instances: [Bug #6087]
    • Array#drop
    • Array#drop_while
    • Array#flatten
    • Array#slice!
    • Array#slice / Array#[]
    • Array#take
    • Array#take_while
    • Array#uniq
    • Array#*

何を言ってるかと言うと、Arrayを継承したクラスを定義した場合の話です。

classMyArray< Arrayend

ary = MyArray.new([1, 2, 3]).drop(1)

p ary       #=> [2, 3]
p ary.class #=> MyArray  # 2.7
p ary.class #=> Array    # 3.0

上記の通り、MyArray#dropなどはMyArrayのインスタンスを返していました。 一方で、MyArray#rotateは2.7でもArrayのインスタンスを返していたので、一貫性がない状態になっていました。 3.0からは、このようなメソッドは一貫してArrayを返すようになりました。

この問題はRuby 2.0のころに指摘されましたが、「直したい気もするけど非互換が気になるので次のメジャーバージョンのときに考えよう(=忘れてしまおう)」という判断になっていました。が、たまたま今年思い出してしまったので、直すことになりました。9年越しの修正。

わりと直前(リリース2ヶ月前)に変わっているので、非互換問題がおきないといいなあ。個人的には、ArrayStringのようなコアクラスはあまり継承しないほうがいいと思います。

(mame)

Stringのサブクラスのメソッドが、サブクラスではなく、Stringクラスのオブジェクトを返すようになった

  • The following methods now return or yield String instances instead of subclass instances when called on subclass instances: [Bug #10845]
    • String#*
    • String#capitalize
    • String#center
    • String#chomp
    • String#chop
    • String#delete
    • String#delete_prefix
    • String#delete_suffix
    • String#downcase
    • String#dump
    • String#each_char
    • String#each_grapheme_cluster
    • String#each_line
    • String#gsub
    • String#ljust
    • String#lstrip
    • String#partition
    • String#reverse
    • String#rjust
    • String#rpartition
    • String#rstrip
    • String#scrub
    • String#slice!
    • String#slice / String#
    • String#split
    • String#squeeze
    • String#strip
    • String#sub
    • String#succ / String#next
    • String#swapcase
    • String#tr
    • String#tr_s
    • String#upcase

前項と同じ変更は文字列の方でも行われています。

なお、この変更で Rails の SafeBuffer クラスが動かなくなっていました。Rails の最新版では修正されています。

(mame)

Dir.globの結果がソートされるようになった

  • Dir.glob and Dir. now sort the results by default, and accept sort: keyword option. [Feature #8709]

そのままです。

# Rubyのパッケージディレクトリで実行するDir.glob("*.c") #=> ["marshal.c", "symbol.c", "regparse.c", "st.c", ...]  # 2.7Dir.glob("*.c") #=> ["addr2line.c", "array.c", "ast.c", "bignum.c", ...]  # 3.0

Ruby 2.7まではDir.globはファイルシステムに依存する順序でファイルを列挙していましたが、Ruby 3.0からはデフォルトでソートされるようになりました。もしソートしてほしくない場合は、Dir.glob("*.c", sort: false)としてください

ファイル列挙はO(n)でできるのに、ソートをするとO(n log n)になってしまう、ということで若干の躊躇がありましたが、現実的にはファイルアクセスに比べて文字列ソートは無視できるほど速いこと、また、Linuxのglob(3)もデフォルトでソートするらしいことが決め手となり、変更されました。

「globの結果はsortして使え」というRubocopのルールがあるらしいですが、Ruby 3.0からは無意味になるのでやめたほうが良さそうです。

(mame)

Windows のデフォルト外部エンコーディングが UTF-8 になった

  • Windows: Read ENV names and values as UTF-8 encoded Strings [Feature #12650]
  • Changed default for Encoding.default_external to UTF-8 on Windows [Feature #16604]

Windowsでは、ロケールによらずに、デフォルトの外部エンコーディング(-Eオプションが指定されないときの Encoding.default_external)が UTF-8 になりました。

> ruby -e 'p Encoding.default_external'
#<Encoding:UTF-8>

> ruby -Ecp932 -e 'p Encoding.default_external'
#<Encoding:Windows-31J>

また、環境変数の値は、ロケールによらず UTF-8 になりました。

> ruby -e 'p ENV["PATH"].encoding'
#<Encoding:UTF-8>

> ruby -Ecp932 -e 'p ENV["PATH"].encoding'
#<Encoding:UTF-8>

(ko1)

IBM720 というエンコーディングの追加

IBM720 と、そのエイリアス CP720 というエンコーディングが追加されたそうです。

(ko1)

Fiber scheduler が導入された

  • Fiber
    • Fiber.new(blocking: true/false) allows you to create non-blocking execution contexts. [Feature #16786]
    • Fiber#blocking? tells whether the fiber is non-blocking. [Feature #16786]
    • Introduce Fiber.set_scheduler for intercepting blocking operations and Fiber.scheduler for accessing the current scheduler. See doc/scheduler.md for more details. [Feature #16786]
  • ConditionVariable
    • ConditionVariable#wait may now invoke the block/unblock scheduler hooks in a non-blocking context. [Feature #16786]
  • IO
    • IO#nonblock? now defaults to true. [Feature #16786]

    • IO#wait_readable, IO#wait_writable, IO#read, IO#write and other related methods (e.g. IO#puts, IO#gets) may invoke the scheduler hook #io_wait(io, events, timeout) in a non-blocking execution context. [Feature #16786]

  • Kernel
    • Kernel.sleep invokes the scheduler hook #kernel_sleep(...) in a non-blocking execution context. [Feature #16786]
  • Mutex
    • Mutex is now acquired per-Fiber instead of per-Thread. This change should be compatible for essentially all usages and avoids blocking when using a scheduler. [Feature #16792]
  • Queue / SizedQueue
    • Queue#pop, SizedQueue#push and related methods may now invoke the block/unblock scheduler hooks in a non-blocking context. [Feature #16786]
  • Thread
    • Thread#join invokes the scheduler hooks block/unblock in a non-blocking execution context. [Feature #16786]

I/O 処理など、実行するとブロックする処理では、それを待っている間に他の独立した処理を行うと効率が良くなることが知られています。これまでは、スレッドを使うか、EventMachine などを使って自分で組み立てていく必要がありました(いわゆる、ノンブロッキングなプログラミング)。これを、I/O などでブロックしたら、他の独立した Fiber を実行するようなスケジューラを、Ruby で記述するための仕組みが Fiber scheduler です。

機能の紹介

Fiber scheduler によって、I/O などの、待ちを多く含んだ大量の処理を並行に行わなければならない用途で、Fiber を使って、スレッドよりも軽量に扱うことができます。このために、イッパイ変更が並んでいますね。

現在の MRI のスレッドは、1つのRubyスレッドに対して1つのOSスレッドを作ります。そのため、生成が重い、上限がけっこうすぐくる、という問題があります。

$ time ruby27 -ve '(1..).each{|i|begin; Thread.new{sleep}; rescue; p [$!, i]; exit; end}'
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]
[#<ThreadError: can't create Thread: Resource temporarily unavailable>, 32627]

real    0m7.305s
user    0m6.726s
sys     0m20.182s

$ time ruby30 -ve '(1..).each{|i|begin; Thread.new{sleep}; rescue; p [$!, i]; exit; end}'
ruby 3.0.0dev (2020-12-21T04:25:03Z master 74a7877836) [x86_64-linux]
[#<ThreadError: can't create Thread: Resource temporarily unavailable>, 32627]

real    0m14.677s
user    0m5.722s
sys     0m10.415s

このシステムだと、3万個程度で上限がきます(OSのプロセス数の上限)。あれ、Ruby 3で時間が倍くらいになってますね...なんでだろ。

Fiber ですと、こんな感じ。

$ time ruby27 -ve 'fs=[]; (1..).each{|i| begin; fs << (f = Fiber.new{Fiber.yield}); f.resume; rescue; p [$!, i]; exit; end }'
ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-linux]
[#<FiberError: can't set a guard page: Cannot allocate memory>, 31745]

real    0m0.452s
user    0m0.244s
sys     0m0.208s

$ time ruby30 -ve 'fs=[]; (1..).each{|i| begin; fs << (f = Fiber.new{Fiber.yield}); f.resume; rescue; p [$!, i]; exit; end }'
ruby 3.0.0dev (2020-12-21T04:25:03Z master 74a7877836) [x86_64-linux]
[#<FiberError: can't set a guard page: Cannot allocate memory>, 31745]

real    0m0.497s
user    0m0.277s
sys     0m0.220s

あれ、数は3万個程度ですね。これは、メモリプロテクションのためにmmapを使っているのですが、この生成上限にあたっているのではないかと思います(Cannot allocate memory とあるのがそれ)。数はおいといて、生成速度を比べると、1桁違います。あと、ちゃんと書いていないですが、メモリ消費も Fiber のほうが少ないです。

このへんが、Fiber は軽量といっている理由になります。

Fiber もスレッドも、どちらも並行処理(たとえば、独立したIO処理、典型的にはウェブリクエストをうけてレスポンスする処理)を行うのは同じですが、スレッドはテキトーなタイミング(処理系依存のタイミングともいう)勝手に切り替わるのに対し、Fiber は自分で "resume/yield"などを利用して切り替えを行う必要があります。これは、勝手に切り替わらない、という Fiber のメリットでもあるのですが、Fiber を用いて IO 処理をやっていると、readなどでブロックしてしまうと切り替えるタイミングを逸してしまうので(他の実行可能な Fiber に処理をうつすことができないので)、readなどブロックするような処理を避けてプログラミングする必要がありました。

Fiber scheduler は、典型的なブロッキングをするような処理(readとか)が起こったら、ユーザーレベル(つまり、Ruby)で記述されたハンドラに飛ばして、自分で non-blocking IO 処理を書いて他の Fiber に処理をうつす、といったことを自分で書くことができるようにする仕組みです。このハンドラを定義するオブジェクトを、ここではスケジューラーと呼んでいます。

現在実行中のスレッドのスケジューラを設定するには、Fbier.set_scheduler(scheduler_object)のように指定します(スレッドローカルな属性です)。

ブロッキングするような処理が起きるとスケジューラーのハンドラが起動されます。現在次のような処理で、スケジューラを呼び出します。

  • ConditionVariable#wait
  • IO#wait_readable, IO#wait_writable, IO#read, IO#write and other related methods (e.g. IO#puts, IO#gets)
  • Kernel.sleep
  • Mutex#lock and related methods
  • Queue#pop, SizedQueue#push and related methods
  • Thread#join

どのメソッドが、どのようなスケジューラーのフックを呼ぶかどうかは、詳細なので立ち入りません(詳細は ruby/fiber.md at master ・ ruby/rubyをご覧ください)。

少し試してみましょう。sleepすると、スケジューラーのハンドラが呼ばれるので確認してみます。method_missingだけを定義したスケジューラを用意して、どのようなフックが呼ばれるか確認してみましょう。

classMySchedulerdefmethod_missing *args
    p args
  endendFiber.set_scheduler(MyScheduler.new)

Fiber.new{
  sleep(10)
}.resume

#=> [:kernel_sleep, 10]

MyScheduler#kernel_sleep(10)というメソッドが呼ばれていることがわかります。スケジューラーは、別の実行可能な Fiber に処理を移してもいいですし、実際に Kernel#sleep(10)を呼びだしてスリープしても良いわけです。

この機能の導入に際し、次のような変更が入っています。

  • Mutex が Fiber local になるといった変更がありました。つまり、Fiber scheduler を利用するプログラムは、スレッドプログラミングと同様に、注意深くロックを行うなどする必要があります。
  • IO は基本的に non-blocking モードになりました(が、普通に使う分には何も変わりません。IO#read してもブロックするように見えます。システム側の設定の話になります)
  • Fiber.new(blocking: false)というパラメータが増えました。true だと、スケジューラが呼ばれなくなります。root fiber (スレッドとセットで生成される Fiber)は、true になっています。
  • スレッド終了時、スケジューラがあり、そのスケジューラに #closeが定義されていれば、それが呼ばれることになりました。

難しそうな機能ですが、実際これを直接使うのは、多分とても難しいので、このインターフェースを直接使うのはあまりおすすめしません。これを利用して非同期 IO を実現する async gem(仕様提案者の Samuel さんが作っているライブラリ)などを利用するといいと思います。

この機能(を使ってスケジューラを提供する gem)を使うべきかどうかですが、既存のプログラムを直接動かせることを目的としているため、いろいろなハックが入っており、動かすことができる可能性は高いです。そして、スレッドの代わりに Fiber を用いることで、高い並行性を達成することができるかもしれません。ただ、これまでのプログラミングモデルと微妙に異なる部分がソコソコあるので、はまると大変だと思います。なので、小さなプログラムから試していくとよいのではないかと思います。目的に合致すると、良いものだと思います。

この新機能をまとめると、Ruby レベルで Fiber を切り替えて動かすスケジューラーを記述するための機能ということができます。この機能により、たとえば大量のウェブリクエストを同時にさばかなくてはならないという、C10K 問題が、Ruby で問題なく処理することができるようになると期待されます。

機能についての個人的な意見

この機能を導入するため、非常に多くの議論がなされました。もっとも本質的には、このスケジューラーを Ruby ユーザーに記述させることができる、という点です。

利点としては、同じスケジューラ実装を、このインターフェースを備えた MRI 以外の実装でも共有できるというものです。また、プログラムに適したスケジューラを自分で書くことができるというのも利点の一つだと思います(90年代のマイクロカーネル研究を思い出します)。

が、個人的にはRubyでかけないようにしたほうが良かったんじゃないかなと思っています。スケジューラが備えるべきインターフェースが何であるか、非常に難しい問題で、現在は結構アドホックな印象を受けます。また、ブロッキングするかもしれない処理には様々なものがあり、Ruby だけでなんとかできるものばかりではありません。というわけで、この方針で進むのは難しいんじゃないかなぁと思っています。最初は、I/O 限定で切り替わる限定的なスケジューラという話だったので、限定的なシチュエーションにおいては良さそうと思ったんですが、汎用的なスケジューラにむかっているので、大丈夫かなぁと少し不安に思っています。

将来的には、スレッドのバックエンドを Fiber が用いている context を用いて良い感じにスケジューリングする(いわゆるM:Nモデル)ものを作って、スレッド自体が Fiber scheduler と同等の性能になるようにしていくと良いのではないかなぁと思っています(基本的な設計はできているので、あとは作るだけ! いつできるだろう)。

(ko1)

Fiberごとのバックトレース情報が取れる Fiber#backtraceFiber#backtrace_locationsが導入された

  • Fiber#backtrace & Fiber#backtrace_locations provide per-fiber backtrace. [Feature #16815]

Thread#backtraceは、そのスレッドが現在実行中のバックトレースを出す機能でしたが、これを Fiber ごとに得る Fiber#backtraceFiber#backtrace_locationsが導入されました。

deffoo = Fiber.yield
defbar = Fiber.yield

f1 = Fiber.new{ foo }; f1.resume
f2 = Fiber.new{ bar }; f2.resume

pp f1.backtrace
#=> ["t.rb:1:in `yield'", "t.rb:1:in `foo'", "t.rb:4:in `block in <main>'"]
pp f2.backtrace
#=> ["t.rb:2:in `yield'", "t.rb:2:in `bar'", "t.rb:5:in `block in <main>'"]

これも、Fiber scheduler で(というか、スケジューラのデバッグで)便利に使うための機能ですね。

(ko1)

Fiber#transferの制限が緩和された

  • The limitation of Fiber#transfer is relaxed. [Bug #17221]

これまで、Fiber#resume/yieldFiber#transferを混ぜることは禁止していたのですが(この Fiber は resume/yield、この Fiber は transfer 専用、のように使ってほしかった)、この制限を緩和して、良い感じに使えるようにしました。詳細はチケットを見てください。簡単にいうと、resume/yield中の Fiber には transfer できない、transfer している Fiber には resume できないなどという制約だけでよさそうだ、というものです(本当はもう少し詳細)。

もともと、「なんかよくわからんけど resume/yield の関係が壊れるから transfer 混ぜられない」というのが、混ぜるの禁止にしていた理由なんですが、きちんと考えると、混ぜてはいけない理由がはっきりしてきたので、よく整理できたということです。

Fiber scheduler まわりでこの制限を緩和してほしい、というリクエストがあり、遠藤さんと延々と議論していたとき、「これで整理できるんじゃない?」というのがふってきて、二人で半日くらい議論して条件を洗い出すことができました。10年くらい気になっていた問題がきれいに解決して、とても嬉しい改善です(でも、影響はほとんどない)。

(ko1)

compaction GC を自動でやってくれる GC.auto_compact = trueが追加された

  • GC.auto_compact= and GC.auto_compact have been added to control when compaction runs. Setting auto_compact= to true will cause compaction to occur during major collections. At the moment, compaction adds significant overhead to major collections, so please test first! [Feature #17176]

Ruby 2.7 から、ヒープの中身をコンパクションする GC.compactが導入されました。これは、手動で好きなタイミングで行おう、というものですが、これを major GC(世代別GC で、時々行うヒープ全体を対象にする GC。遅い)のときに行おうというものです。

GC.compactについては、開発者の Aaron さんが解説する Rubyconf 2020 の動画がアップロードされていました: Automatic GC Compaction in MRI - Aaron Patterson - YouTube

GC.auto_compact = trueとすることで、major GC が起こるとコンパクションも実行してくれます。そのため、定期的にメモリの掃除をしてくれることになり、メモリ効率の向上、および局所性向上による性能改善が期待できます。が、ここにも書いてある通り、コンパクション自体が結構なオーバヘッドになるので、自分のアプリで効くかどうか確認してみるといいと思います。デフォルトは、そういうことで false です。

テクニカルには read-barrier とか導入していてマジかって感じです。色々大変そうで避けていたんですが、ちゃんと動くんだなぁ。

正直、まだ実装がこなれていないような気がするので(拡張ライブラリあたりが怪しいです)、みんながすぐにこれを使うってのには、ならない気がします(はまらなければ、使ってもいいと思います)。

(ko1)

Hash#exceptが導入された

  • Hash#except has been added, which returns a hash excluding the given keys and their values. [Feature #15822]
  • ENV.except has been added, which returns a hash excluding the given keys and their values. [Feature #15822]

ActiveSupportにあるHash#exceptが組み込みになりました。

{ a: 1, b: 2, c: 3 }.except(:b) #=> {:a=>1, :c=>3}

ENV#exceptも同様に追加されています。

要望は以前からありましたが、「名前がしっくり来ない、組み込みにするほどのユースケースがあるのかよくわからない」ということで先送りになっていました。excludeのような名前も検討されたようですが、結局ActiveSupportに従うことになりました。なお、exceptは「~を除いて」という前置詞しか知りませんでしたが、「除外する」という動詞の用法もあるようです。

(mame)

Hash#transform_keysがハッシュを受け取るように

  • Hash#transform_keys now accepts a hash that maps keys to new keys. [Feature #16274]

ハッシュのキーを変換するHash#transform_keysが、変換の対応をHashで示せるようになりました。

# ↓新機能
{ a: 1, b: 2, c: 3 }.transform_keys({ a: :A })              #=> { A: 1, b: 2, c: 3 }# ↓従来の機能で同じことをやるとしたら
{ a: 1, b: 2, c: 3 }.transform_keys {|k| k == :a ? :A : k } #=> { A: 1, b: 2, c: 3 }

JSONの変換のようなときに便利のような気はします。

(mame)

Kernel#clonefreeze: trueとしたら freeze されるようになった

  • Kernel#clone when called with freeze: false keyword will call #initialize_clone with the freeze: false keyword. [Bug #14266]
  • Kernel#clone when called with freeze: true keyword will call #initialize_clone with the freeze: true keyword, and will return a frozen copy even if the receiver is unfrozen. [Feature #16175]

2つの変更が語られています。いずれも細かい内容です。

まず1つめの変更について。Kernel#cloneはオブジェクトを複製するメソッドですが、freezeされたオブジェクトをcloneしたらfreezeされた複製を返します。

ary = [1, 2, 3].freeze
p ary.clone.frozen? #=> true

しかし、cloneでfreeze状態は保存してほしくないケースがあり、Ruby 2.4でfreeze: falseというキーワード引数が導入されました。

ary = [1, 2, 3].freeze
p ary.clone(freeze: false).frozen? #=> false

このとき、freeze: trueというのは「従来どおり、freeze状態を保存する」という意味になりました。よって、元のオブジェクトがfreezeされていない場合、freezeされていない複製が返されていました。

ary = [1, 2, 3].freeze
p ary.clone(freeze: true).frozen? #=> true

s = "str"# freeze されていない
p s.clone(freeze: true).frozen? #=> false

が、「freeze: trueと書いてあるのにfreezeされていない複製を返すのはバグでは?」という指摘が来たので、そうするようになりました。

s = "str"# freeze されていない
p s.clone(freeze: true).frozen? #=> Ruby 3.0 では true

なんだかレトロニムみたいな話ですね。

もうひとつの話の変更をかいつまんで。これはSetクラスを clone(freeze: false)したときに起きた問題に関する話です。Setクラスは内部的にHashで集合を表現しているのですが、Set#freezeすると内部のHashもfreezeします。よって、freezeしたSetインスタンスをclone(freeze: false)で複製しても、内部的なHashはfreezeされたままになるという問題がありました。そこで、clone時に呼ばれるinitialize_cloneメソッドにfreeze:キーワードを渡すようにして、内部的なHashのcloneにfreeze:キーワードを渡せるように変更されました。

(mame)

eval内のファイル名や行番号をbindingから継承しないようになった

  • Kernel#eval when called with two arguments will use "(eval)" for __FILE__ and 1 for __LINE__ in the evaluated code. [Bug #4352]
  • Binding#eval when called with one arguments will use "(eval)" for __FILE__ and 1 for __LINE__ in the evaluated code. [Bug #4352] [Bug #17419]

evalの中での__FILE____LINE__が微妙に変わります。次の例を見てください。

1: # eval-test.rb2: b = binding
3:
4: eval("p __LINE__", b) #=> Ruby 2.7では警告とともに2、Ruby 3.0では1

このコードは、Ruby 2.7で実行すると、次のように(警告とともに)2が出ていました。

$ ruby eval-test.rb
eval-test.rb:2: warning: __LINE__ in eval may not return location in binding; use Binding#source_location instead
eval-test.rb:4: warning: in `eval'
2

Ruby 2.7までのevalはデフォルトで、渡されたbindingのファイル名や行番号を継承していました。ここで表示される2は、bindingが作られた行番号です。

しかしこれは時として混乱の元でした。次の例を見てください。

1: b = binding
2:
3: eval(<<END, b)
4:5: raise6: END

これをRuby 2.7で実行すると、次のようなバックトレースが出ます。

$ ruby2.7 eval-test.rb
Traceback (most recent call last):
        2: from eval-test.rb:3:in `<main>'
        1: from eval-test.rb:3:in `eval'
eval-test.rb:2:in `<main>': unhandled exception

eval-test.rbの2行目で例外が出たことになっていますが、その行は空行です。謎でしかない。これは、bindingのファイル名と行番号を暗黙的に引き継いだ結果です。

Ruby 3.0からは、この引き継ぎを行わないようになりました。

$ ruby3.0 eval-test.rb
(eval):2:in `<main>': unhandled exception
        from eval-test.rb:3:in `eval'
        from eval-test.rb:3:in `<main>'

紛らわしい結果がなくなりました。

なお、もし従来どおりの挙動にしたい場合は、eval("p __LINE__", b, *b.source_location)のようにBinding#source_locationを使ってください。また、Binding#evalも同様に変わっています。b.eval(src)b.eval(src, *b.source_location)としてください。

(mame)

Kernel#lambdaにブロック引数を渡したら警告を出すようになった

  • Kernel#lambda now warns if called without a literal block. [Feature #15973]

どうやら、lambda(&pr)のように渡すと、Procオブジェクトを lambda に変換してくれる、という誤解があったようで、いくつかの場所で実際に使われていました。が、実はそんな機能は無いので、lambda{ ... }のようにブロックを指定するのではなく、lambda(&pr)のように Proc を渡した場合には警告を出すようになりました。

lambda(&proc{})
#=> warning: lambda without a literal block is deprecated; use the proc without lambda instead

将来的にはエラーになるのかなぁ。

(ko1)

後から行った Module#includeが無視されなくなった

  • Module#include and Module#prepend now affect classes and modules that have already included or prepended the receiver, mirroring the behavior if the arguments were included in the receiver before the other modules and classes included or prepended the receiver. [Feature #9573]

モジュールのincludeの順序によっては、includeが無視されるように見えるケースがありました。それが修正されたという内容です。

# モジュールを 2 つ作るmoduleM1; endmoduleM2; end# クラス C は M1 を include するclassCincludeM1end# M1 が後から M2 を include するmoduleM1includeM2end# C のスーパークラスに M2 が入っていなかったが、3.0 から入るようになった
p C.ancestors #=> [C, M1, Object, Kernel, BasicObject]      # 2.7
p C.ancestors #=> [C, M1, M2, Object, Kernel, BasicObject]  # 3.0

このように、あとから M2 を include しているのが無視されていました。無視されていたのは実装の都合でしたが、気合で修正されました。

個人的なオススメは、このように、あとからモジュールを include するようなことはしないことです。あとから include/prepend は他にも問題があることが知られています(include の順序によっては、ancestors に同じモジュールが複数回現れてしまうとか、prepend を絡めると意味がわからなくなるとか)。

(mame)

private attr_reader :fooと書けるようになった

  • Module#public, Module#protected, Module#private, Module#public_class_method, Module#private_class_method, toplevel "private" and "public" methods now accept single array argument with a list of method names. [Feature #17314]

  • Module#attr_accessor, Module#attr_reader, Module#attr_writer and Module#attr methods now return an array of defined method names as symbols. [Feature #17314]

  • Module#alias_method now returns the defined alias as a symbol. [Feature #17314]

表題のとおり、private な attr_reader などをシンプルに書ける様になりました。

具体的な変更としては、(1) attr_reader や attr_accessor が定義したメソッドのシンボルの配列を返すようになった、(2) public や private が配列を引数に受け取れるようになった、です。

classFoo# (1) attr_reader や attr_accessor が定義したメソッドのシンボルの配列を返すようになったattr_accessor:foo, :bar#=> [:foo, :foo=, :bar, :bar=]# (2) public や private が配列を引数に受け取れるようになったprivate [:foo, :foo=, :bar, :bar=]

  # 2 つを組み合わせると、次のように書いても同じ意味になるprivateattr_accessor:foo, :barend

また、alias_methodメソッドも定義されたメソッドのシンボルを返すようになりました。これも private alias_method :foo, :barと書けることを狙ったものです。

(mame)

Proc の等価判定(Proc#==, Proc#eql?)が少し緩和された

  • Proc#== and Proc#eql? are now defined and will return true for separate Proc instances if the procs were created from the same block. [Feature #14267]

これまで、Proc#==は、同じオブジェクトかどうかで判断していました(というか、Proc#==はなくて、Object#==が使われていた)。が、この制限を緩和し、同じメソッド呼び出しのブロックパラメータで作られたProcは、Proc#==でtrueを返すようになりました。正直、これを読んでも意味わからないと思うのですが、これが関係するところはマレだと思うので、あまり気にしなくていいと思います。基本的には、Proc#==なんて使わないでください。また、Hash のキーにするべきでもないでしょう。

一応、ちゃんと書いておきますと、これは Ruby 2.5 で導入された lazy proc allocation(Ruby 2.5 の改善を自慢したい - クックパッド開発者ブログ「Lazy Proc allocation によるブロックパラメータを用いたブロック渡しの高速化」 )の非互換を解消するためのものです。

defbar&b
  b
enddeffoo(&b1)
  b2 = bar(&b1)
  p b1 == b2
  p b1.equal? b2
end

foo{}

#=>              b1 == b2   b1.equal? b2# Ruby 2.4 以前  true       true# Ruby 2.5-2.7   false      false# Ruby 3.0       true       false

Ruby 2.4 では、b1Procを生成し、それをbar(&b1)として渡しても、すでにProcが生成されているので、単にその Proc を渡すだけでした。そのため、b1.equal? b2は true でした。

しかし、Lazy Proc Allocation によって、Proc の生成が遅延されてしまうので、bar で初めて Proc を作り、そしてその情報は foo 側には渡らないので foo でも新たに Proc を作り、b1 == b2b1.equal? b2ともに false になってしまっていたのでした。この挙動自体は非互換として当時から認識していたのですが、「まー誰も困らんやろ」と思っていたら、なんか RSpec で踏んだらしいんですよね。

ということで、どうするかと思っていたら、Proc#==を変えればいいのでは(違うオブジェクトでも、こういうケースなら true になるような Proc#==にすれば良いのでは)という素晴らしい解決策を得て、解決したのでした。

(ko1)

Ractor による並列並行プログラミングのサポート

  • New class added to enable parallel execution. See doc/ractor.md for more details.

Rubyで簡単に並列並行プログラミングを行うための Ractor が導入されました。

まだ、実験的機能(仕様が不安定、実装が不安定)なので、最初に Ractor.newで Ractor を生成するとき、警告が出るようになっています。

細かい仕様については、別の資料をご参考にしてください。下記に、私の発表した資料へのリンクを掲載しておきます。

(ko1)

Random::DEFAULT が非推奨に

  • Random::DEFAULT now refers to the Random class instead of being a Random instance, so it can work with Ractor. [Feature #17322]

  • Random::DEFAULT is deprecated since its value is now confusing and it is no longer global, use Kernel.rand/Random.rand directly, or create a Random instance with Random.new instead. [Feature #17351]

デフォルトの乱数生成器 Random::DEFAULTが非推奨になりました。代わりに Randomクラスオブジェクトが利用できます。また、Random::DEFAULTは、Randomクラスのインスタンスだったのが、Randomクラス自体が返るようになりました。

p Random::DEFAULT == Random#=> trueRandom::DEFAULT.srand(0)    # seed を指定して
p Random::DEFAULT.rand(10)  # => 5
p Random::DEFAULT.bytes(3) #=> "\xC0\xCC!"# Random クラスで同じことができるRandom.srand(0)
p Random.rand(10) #=> 5
p Random.bytes(3) #=> "\xC0\xCC!"

非推奨になったので、-w 付きで実行しているときに Random::DEFAULTを参照すると警告が出るようになりました。

$ ruby -w -e 'p Random::DEFAULT'
-e:1: warning: constant Random::DEFAULT is deprecated
Random

もともと、Randomクラスには randなどのメソッドがくっついていました。これらのメソッドは、Random::DEFAULTと同じ乱数生成器を参照して実行します。そのため、Random::DEFAULTの代わりに Randomを用いれば、だいたいうまくいくようになっています。ただ、クラスになったので、Marshalなどに対応しなくなったのが若干の非互換になっています(一応、公開されている gem を調べた限り、そのようなことをしているものはありませんでした)。

なんで Randomクラスが特異メソッドとして randなどを持っているのかわからなかったのですが(私は初めて知った)、聞いてみると、デフォルトの乱数生成器を用いるメソッドを置く場所が欲しかった、ということでした(Random.rand()などがついたのは 1.9.2、Random::DEFAULTができたのは 1.9.3で、ちょっと後なんですね)。すでに Kernel#randなどはありましたが、Rnadom#bytesなどは、確かに置く場所が困りそうでした。

この変更の背景をご紹介します。

Random::DEFAULTは、これまで Kernel#randなどが利用する疑似乱数生成器をさしていました。つまり、rand(10)などを実行すると、この Random::DEFAULTの生成器の乱数を消費していたわけです。

しかし、Ractor が入ると、同時に複数の Ractor が生成器を利用してしまうため、生成器の実装をスレッドセーフにする必要がありました。ただ、その対応は結構大変だなぁ、というので、生成器は Ractor ローカルとするのが良さそう、となりました(つまり、乱数生成器は Ractor をまたいで共有されない)。

現在の定義だと、Kernel#randなどは、唯一存在する Random::DEFAULTを乱数生成器として利用する、という定義なので、これがネックになりました。Ractor ごとに持つためには、Random::DEFAULTを使う、というわけにはいかないものですから。そこで、Random::DEFUALTの意味を変更する必要が出てきました。候補としては、次の二つです。

  • (1) Random::DEFAULTに特殊な Randomインスタンスを設定して、それは Ractor local なデフォルトの乱数生成器を参照する
  • (2) Rnadom::DEFAULTは、なぜか Randomインスタンスがもつメソッドを実装しているので、Random::DEFAULT = Randomという定義にしてしまい、Random.randなどは Ractor local な乱数生成器を参照する、という意味に変更する

というわけで、実装の面倒が少ない (2) を選ぶことにしました。特異メソッドなら、Ractor local なものを参照する、という特別な意味があります、と言い張っても受け入れらそうだし。

あまり、乱数生成器を意識することはないのではないかと思うのですが、ちょっと変わっているということはご承知おきください。

(ko1)

Symbol#to_procが lambda を返すようになった

Symbol#to_procで生成する Proc が lambda となるようになりました。

Proc は proc{}/Proc.new{}およびメソッドのブロック仮引数でうけて生成する場合と、lambda{}->{}で生成する場合で挙動が異なります。ここでは、前者をproc、後者を lambda と呼ぶことにします。Proc#inspectで、lambda の場合 lambdaと出ます。

p ->{} #=> #<Proc:0x00000280db845220 t.rb:1 (lambda)>

proc と lambda のもっともわかりやすい違いは、引数の数のチェック機能でしょう。proc は曖昧に解釈するので、渡された実引数の数と仮引数の数が違っても、何もなくなんとく良い感じに(この良い感じがバグというか混乱を呼んでいるんですが...)解釈します。lambda は違うとエラーになります。

proc{|a| p a}.call(1, 2)
#=> 1
->a{p a}.call(1, 2)
#=> `block in <main>': wrong number of arguments (given 2, expected 1) (ArgumentError)

で、Symbol#to_procで作ったProcオブジェクトは、lambda っぽい挙動になるのに、inspect しても lambda って出てこないのは変だよね、ということで、lambda になりました。

pr = :object_id.to_proc
p pr
#=> #<Proc:0x00000236441f1270(&:object_id) (lambda)> # ruby 3.0 から (lambda) がついた

p pr.call(1) # 1.object_id と同じ#=> 1.object_id の結果 3 が返る

p pr.call(1, 2) # 1.object_id(2) と同じ#=> in `object_id': wrong number of arguments (given 1, expected 0) (ArgumentError)

(ko1)

シンボルの名前に対応する文字列が返る Symbol#nameの追加

  • Symbol#name has been added, which returns the name of the symbol if it is named. The returned string is frozen. [Feature #16150]

:sym.name #=> "sym"となるような Symbol#nameが導入されました。でも、String#to_sでも同じような挙動だったんですよね。何が違うかというと、返ってくる文字列が frozen になったのでした。frozen になっているから、重複排除、つまり何回読んでも同じ文字列オブジェクトを返すことが可能になりました。みんな、文字列生成を排除したくてしょうがないんですね。

もともと、Symbol#to_sを freeze にしてしまおう、って提案があって、チャレンジされてたんですが、非互換がつらいということで reject になりました。なんか別の方法がないか、ということで、Symbol#nameという別案が用意されました。これ、RubyKaigi takeout 2020 のあとの zoom で、なんか盛り上がって入れたんでしたっけかね?

(ko1)

デッドロック検知を無効にするオプションが導入された

  • Thread.ignore_deadlock accessor has been added for disabling the default deadlock detection, allowing the use of signal handlers to break deadlock. [Bug #13768]

スレッドでロックをお互い待ってしまってにっちもさっちもいかなくなるような場合、デッドロックと呼ばれます。Ruby には簡単なデッドロック検出機能があり、すべてではないですが、デッドロックになったときに例外を発生させ、(多分バグでしょうから)デバッグに有用な情報を出力します。

q = Queue.new

Thread.new{
  q.pop
}
q.pop

__END__t.rb:6:in `pop': No live threads left. Deadlock? (fatal)2 threads, 2 sleeps current:0x000001b07776b280 main thread:0x000001b0721a80b0* #<Thread:0x000001b07221ca68 sleep_forever>   rb_thread_t:0x000001b0721a80b0 native:0x0000000000000128 int:0* #<Thread:0x000001b0777790d8 t.rb:3 sleep_forever>   rb_thread_t:0x000001b07776b280 native:0x0000000000000184 int:0   from t.rb:6:in `<main>'

この例では、1つの Queue をすべてのスレッドが待っているので、誰も起こすことは無いだろうということで、デッドロックと認定し、エラーを出力しています。

さて、世の中にはシグナルの到着により、スレッド実行を復帰させたい、というプログラムがあります。

q = Queue.new

trap(:INT){ q << 1 }
q.pop

__END__t.rb:4:in `pop': No live threads left. Deadlock? (fatal)1 threads, 1 sleeps current:0x0000019cecf68630 main thread:0x0000019cecf68630* #<Thread:0x0000019cecfdca98 sleep_forever>   rb_thread_t:0x0000019cecf68630 native:0x0000000000000128 int:0   from t.rb:4:in `<main>'

このような場合でも、trap の存在に気づかず、デッドロックと判定してしまいます。でも、プログラマー的にはデッドロックじゃないので何とかしてほしい、というリクエストが来ていました。

いろいろ議論したのですが(trap が1つでも設定されていれば deadlock 検知をスキップするとか、いやでもそれがプログラムの実行を再開するとは限らないしな、とか)、結局「デッドロック検知自体をオフにする」機能でいいのではないか、となりました。それが Thread.ignore_deadlock = trueです。

Thread.ignore_deadlock = true
q = Queue.new

trap(:INT){ q << 1 }
q.pop                 # Ctrl-C で終了する

まぁ、あんまり難しいことしないほうがいいですよ、シグナルとか難しい。

(ko1)

警告周りのメソッドが categoryキーワードを受け取るようになった

  • Warning#warn now supports a category keyword argument. [Feature #17122]

Ruby 2.7から、警告にカテゴリという概念が導入されました。いまのところ:deprecated:experimentalと「なし」という3種類のカテゴリだけです。 :deprecated:experimentalのカテゴリに属す警告はRubyのインタプリタ内部でしか作れなかったのですが、ユーザもカテゴリに属す警告を出せるようになりました。

warn("foo is deprecated", category: :deprecated)

上の警告は、Warning[:deprecated] = trueを有効にしていないと表示されません。

また、警告発生をフックするメソッドWarning.warnがあるのですが、これにもcategoryの情報が渡されるようになりました。

defWarning.warn(msg, category: nil)
  p [msg, category]
end

warn("foo is deprecated", category: :deprecated)
  #=> ["foo is deprecated", :deprecated]

(mame)

■標準ライブラリのアップデート

ライブラリも、いろいろアップデートしました。NEWS にいくつか載っていますが、今回は調べるのが面倒なので、スキップします。

  • BigDecimal

    • Update to BigDecimal 3.0.0

    • This version is Ractor compatible.

  • Bundler

    • Update to Bundler 2.2.3
  • CGI

    • Update to 0.2.0

    • This version is Ractor compatible.

  • CSV

    • Update to CSV 3.1.9
  • Date

    • Update to Date 3.1.1

    • This version is Ractor compatible.

  • Digest

    • Update to Digest 3.0.0

    • This version is Ractor compatible.

  • Etc

    • Update to Etc 1.2.0

    • This version is Ractor compatible.

  • Fiddle

    • Update to Fiddle 1.0.5
  • IRB

    • Update to IRB 1.2.6
  • JSON

    • Update to JSON 2.5.0

    • This version is Ractor compatible.

  • Set

    • Update to set 1.0.0

    • SortedSet has been removed for dependency and performance reasons.

    • Set#join is added as a shorthand for .to_a.join.

    • Set#<=> is added.

  • Socket

  • Net::HTTP

    • Net::HTTP#verify_hostname= and Net::HTTP#verify_hostname have been added to skip hostname verification. [Feature #16555]

    • Net::HTTP.get, Net::HTTP.get_response, and Net::HTTP.get_print can take the request headers as a Hash in the second argument when the first argument is a URI. [Feature #16686]

  • Net::SMTP

    • Add SNI support.

    • Net::SMTP.start arguments are keyword arguments.

    • TLS should not check the host name by default.

  • OpenStruct

    • Initialization is no longer lazy. [Bug #12136]

    • Builtin methods can now be overridden safely. [Bug #15409]

    • Implementation uses only methods ending with !.

    • Ractor compatible.

    • Improved support for YAML. [Bug #8382]

    • Use officially discouraged. Read OpenStruct@Caveats section.

  • Pathname

    • Ractor compatible.
  • Psych

    • Update to Psych 3.3.0

    • This version is Ractor compatible.

  • Reline

    • Update to Reline 0.1.5
  • RubyGems

    • Update to RubyGems 3.2.3
  • StringIO

    • Update to StringIO 3.0.0

    • This version is Ractor compatible.

  • StringScanner

    • Update to StringScanner 3.0.0

    • This version is Ractor compatible.

■非互換

正規表現リテラル、および Range オブジェクトが freeze された

だいたい Ractorの都合なんですが、正規表現リテラルとRangeオブジェクトのすべてが freeze されることになりました。

p /abc/.frozen?             #=> Ruby 3.0 から true
p /a#{42}c/.frozen?         #=> Ruby 3.0 から true

p Regexp.new('abc').frozen? #=> 変わらず false

p (1..2).frozen?            #=> Ruby 3.0 から true
p Range.new(1, 2).frozen?   #=> Ruby 3.0 から true

まぁ、誰もこれらのオブジェクトを変更しないよね、と思うので、普通の人には気にしなくてもいい変更じゃないかと思います。

Regexp.new('abc')が freeze されていないのは、実際にこれを変更する人がいたためです(特異メソッドを追加していた)。そんな非互換気にしなくていいよ、どんどん変更しようぜー、という意見もあったんですが(Matzとか)、ここは保守的にいきました。やる気のある人がいれば、これも freeze されるかもしれません。

こんな感じで、Immutable っぽいオブジェクトはどんどん freeze されています。

関係ないけど、その freeze 化の最初のほう、Symbolは 2013 年に freeze されました。

* include/ruby/ruby.h: make Symbol objects frozen. ・ ruby/ruby@1e27eda

コミットメッセージで "I want to freeze this good day, too."って寿いでますけど、これ、私が結婚した日だったんですよね。記念コミット。

(ko1)

Hash#eachが常に2要素配列をyieldするように

  • EXPERIMENTAL: Hash#each consistently yields a 2-element array [Bug #12706]
    • Now { a: 1 }.each(&->(k, v) { }) raises an ArgumentError due to lambda's arity check.

一言で言えば、最適化のバグ修正です。順に説明します。

Hash は基本的に、キーと値をタプルにした配列を yield します。

{ a: 1 }.each {|ary| p ary } #=> [:a, 1]

しかし、引数が2つあるときはautosplatされます。

{ a: 1 }.each {|k, v| p k } #=> :a

このとき、いちいち配列を作って分解するのは無駄なので、引数が2つあるときは内部的に配列を作らないようにする最適化が行われていました。

しかしこの最適化は、ブロックがlambdaであるときでも適用されてしまっていました。lambdaはautosplatをしないので、引数の数が間違っているという例外が出るのが正しかったです。3.0では原則に従い、ブロックがlambdaのときは例外を投げるようになりました。

# Ruby 2.7
{ a: 1 }.each(&-> (k, v) { p k }) #=> :a# Ruby 3.0
{ a: 1 }.each(&-> (k, v) { p k }) #=> ArgumentError (wrong number of arguments (given 1, expected 2))

(mame)

標準出力がクローズされた後に出力しようとしてもEPIPE例外を投げないようになった

  • When writing to STDOUT redirected to a closed pipe, no broken pipe error message will be shown now. [Feature #14413]

細かい改善です。Ruby 2.7までは、rubyの出力をheadなどで途中で止めると、rubyの例外バックトレースを見かけることがあったと思います。

$ ruby -e 'loop { puts "foo" }' | head
foo
foo
foo
foo
foo
foo
foo
foo
foo
foo
Traceback (most recent call last):
        5: from -e:1:in `<main>'
        4: from -e:1:in `loop'
        3: from -e:1:in `block in <main>'
        2: from -e:1:in `puts'
        1: from -e:1:in `puts'
-e:1:in `write': Broken pipe @ io_writev - <STDOUT> (Errno::EPIPE)

これは、クローズされたパイプに書き込みを行っていたためでした。しかし、このバックトレースは特に便利ではないこと、他 のインタプリタでは何も言わずに終了することから、Ruby 3.0からは同様に何も言わずに終了するようになりました。

(mame)

定数のTRUEFALSENILが定義されないようになった

  • TRUE/FALSE/NIL constants are no longer defined.

よく知らないんですが、非常に古代のrubyでは、trueやfalseやnilは、TRUEやFALSEやNILでした *6。それが現代でも互換性のためになんとなく残され続けていたのですが、ついに削除されました。お疲れさまでした。

(mame)

Integer#zero?が改めて定義された

  • Integer#zero? overrides Numeric#zero? for optimization. [Misc #16961]

これまで、Integer#zero?はなくて、スーパークラスの Numeric#zero?が使われてきていたんですが、高速化のために Integer#zero?を改めて定義しました、という話です。ほぼ影響はないんですが、万が一 Numeric#zero?を再定義しても、Integer#zero?には影響を与えないことになります。

(ko1)

Enumerable#grepgrep_vに正規表現を渡してブロックを渡さなかった場合、$~を更新しなくなった

  • Enumerable#grep and grep_v when passed a Regexp and no block no longer modify Regexp.last_match [Bug #17030]

見出しの通りです。

["foo", "bar", "baz", "qux"].grep(/ba./)

p $~#=> #<MatchData "baz"> in 2.7
p $~#=> nil in 3.0

ary.grep(REGEXP)ary.select {|e| e.match?(REGEXP) }より遅い(MatchData オブジェクトを生成するため?)、という問題に対する対応のようです。非互換を入れずに最適化できるところを探していこう、という雰囲気だった気がするのですが、気づいたら非互換が入ってました。大丈夫かな。

(mame)

open-uri が Kernel#openを上書き定義しなくなった

  • Requiring 'open-uri' no longer redefines Kernel#open. Call URI.open directly or use URI#open instead. [Misc #15893]

みんなが愛した open-uri の Kernel#openが消えました。今後は URI.openを使ってください。

require"open-uri"# 2.7 では警告付きで動いていた、3.0 ではエラー
open("https://example.com") {|f| f.read }
  #=> No such file or directory @ rb_sysopen - https://example.com (Errno::ENOENT)# 2.7 でも 3.0 でも動くURL.open("https://example.com") {|f| f.read }
  #=> "<!doctype html>\n<html>\n..."

セキュリティ向上のためだそうです。Kernel#openはファイルを開くだけでなく、パイプ経由でコマンドを実行できたり、open-uriの拡張でHTTPフェッチができたりする大変便利なメソッドです。しかしこれは攻撃者にとっても便利すぎるきらいがあるということで、ファイルを開く機能専用のFile.open("...")や、URLをフェッチする機能専用のURI.open("...")などに分割整理が進んでいます。その一環として、open-uriがKernel#openを上書きするのもやめたようです。

(mame)

SortedSetが削除された

  • SortedSet has been removed for dependency and performance reasons.

set.rb に抱き合わせで実装されていた SortedSetが別の gem に分離されました。

SortedSet にアクセスすると例外が出ます。

require"set"SortedSet#=> The `SortedSet` class has been extracted from the `set` library.You must use the `sorted_set` gem or other alternatives. (RuntimeError)

削除された理由は、SortedSetが標準添付でない rbtree gem に依存していること(rbtree がないときは pure Ruby の実装が動くけれど、それは遅いこと)だそうです。

gem install sorted_setすれば、そのまま動くようになります。実は、rbtree gem が 3.0.0 対応していないために直前まで動かなかった(本記事を書いて試したことで気づけた)のですが、メンテナの knu さんがリリースまでに対処してくれました。

(mame)

■標準ライブラリの非互換

Default gem 化

  • Default gems
    • The following libraries are promoted the default gems from stdlib.
      • English
      • abbrev
      • base64
      • drb
      • debug
      • erb
      • find
      • net-ftp
      • net-http
      • net-imap
      • net-protocol
      • open-uri
      • optparse
      • pp
      • prettyprint
      • resolv-replace
      • resolv
      • rinda
      • set
      • securerandom
      • shellwords
      • tempfile
      • tmpdir
      • time
      • tsort
      • un
      • weakref
    • The following extensions are promoted the default gems from stdlib.
      • digest
      • io-nonblock
      • io-wait
      • nkf
      • pathname
      • syslog
      • win32ole

これらのライブラリが default gem 化されました。Gemfile にバージョン指定があると、そちらが利用されます。

(ko1)

Ruby インストール時に、インストールされなくなったライブラリ

上記ライブラリが、Ruby インストール時にインストールされなくなりました。gem として別途インストールする必要があります。

WEBrick が一緒にインストールされなくなるのは、結構大きい変更ですね。時代を感じます。

(ko1)

C API updates

いくつか、C 拡張ライブラリを書くための C API が更新されています。

$SAFEに関する C API が削除されています。

  • C API header file ruby/ruby.h was split. [GH-2991] Should have no impact on extension libraries, but users might experience slow compilations.

今まで、ruby.hという大きなヘッダファイルにいろいろ書いてあったのを、複数のファイルに分割しています。 ただ、ruby.hがこれまで通り、すべてを include しているので、拡張ライブラリのビルドに利用する分には変更ありません。

  • Memory view interface [EXPERIMENTAL]

    • The memory view interface is a C-API set to exchange a raw memory area, such as a numeric array and a bitmap image, between extension libraries. The extension libraries can share also the metadata of the memory area that consists of the shape, the element format, and so on. Using these kinds of metadata, the extension libraries can share even a multidimensional array appropriately. This feature is designed by referring to Python's buffer protocol. [Feature #13767] [Feature #14722]

メモリ上の(多次元)配列データを、プロセス内の他のライブラリなどとメタデータ付きで交換するための Memory view interface が追加されました。主に、大きな行列データや画像データなどを、あるライブラリで処理しているときに、別のライブラリに渡して処理をしてもらう、といった用途で利用されます。Python だと buffer protocol と呼ばれている機能を参照して追加されたそうです。

対象となるライブラリが X と Y の2つであれば、X->Y、Y->X のデータの変換器を作るだけでよさそうですが、これが数が増えると変換器の数がどんどん増えていきます。Memory view interface を用いれば、統一されたメタデータのもとで交換することができるので、変換器を作らなくても良くなります。また、生のメモリをそのまま渡すことができるので、何か冗長なフォーマット(例えば CSV)に変換して渡す、といったことが不要になるので、性能的な利点もありそうです。

開発された mrkn さんによる記事も公開されています:MemoryView: Ruby 3.0 から導入される数値配列のライブラリ間共有のための仕組み - Speee DEVELOPER BLOG

  • Ractor related C APIs are introduced (experimental) in "include/ruby/ractor.h".

Ractor に関する C API が少し追加されました。正直、これで足りているのかわからないのですが、とりあえず必要かな、と思うところを足しています。

(ko1)

■実装の改善

メソッドキャッシュが刷新された

  • New method cache mechanism for Ractor [Feature #16614]

    • Inline method caches pointed from ISeq can be accessed by multiple Ractors in parallel and synchronization is needed even for method caches. However, such synchronization can be overhead so introducing new inline method cache mechanisms, (1) Disposable inline method cache (2) per-Class method cache and (3) new invalidation mechanism. (1) can avoid per-method call synchronization because it only uses atomic operations. See the ticket for more details.

メソッド探索のたびに、クラス継承木を辿ってメソッドを探し当てるのは時間がかかるので、メソッド探索の結果をある程度キャッシュするというのがメソッドキャッシュです。

Ruby 2.7 までは、二つのメソッドキャッシュを使っていました。

  • インラインメソッドキャッシュ:バイトコードにキャッシュを突っ込んでおく。Ruby 1.9 (YARV) から導入
  • グローバルメソッドキャッシュ:固定長の1個のテーブルを用意して、そこにメソッド探索結果を保存しておく。すごい古い Ruby からほぼ同じものを利用

それぞれちょっとずつ改善していっていたのですが、今回がらっと変更しました。というのも、複数の Ractor から同時にアクセスすると、最もヒットすることが期待される(実際、90%以上はだいたいヒットする)インラインメソッドキャッシュにおいて、毎回ロックが必要になる、という構造だったからです。ロックを扱うと、オーバヘッドがすごいので、ここではロックの不要なデータ構造が必要になります。

そこで、次のように変更しました。

  • (1) インラインメソッドキャッシュを、毎回ロックを取らなくてもよい仕組みにした
  • (2) グローバルメソッドキャッシュをやめ、クラスごとのキャッシュにした

仕組みをちゃんと説明するのはとても面倒なんですが、(1) インラインキャッシュについてのアイディアとしては、これまで1つのインラインキャッシュを都度更新してきたのが、キャッシュに必要な情報を1オブジェクトとしてまとめておいて、キャッシュするときには、バイトコードからそのオブジェクトへの参照を保存するというアトミックな処理で済むようにした、というものです。

(1) の変更のために、既存のグローバルメソッドキャッシュでは不足があり(そもそも色々不満があった)、この度 (2) クラスごとのメソッドキャッシュを用意しました。

性能改善セクションにあるんですが、実は Ruby 3.0 でマイクロベンチマークの性能が (JIT なしの場合) 少し落ちていて、これがその原因の一つです。ごめんよ。でも並列化してるから許して。

(ko1)

superで必要なメソッド探索を、結果をキャッシュすることで高速化した

  • super is optimized when the same type of method is called in the previous call if it's not refinements or an attr reader or writer.

superで呼び出すメソッドは、Ruby 2.7 以前では毎回メソッド探索をまじめにしていたのですが、今回探索結果をほかのメソッド呼び出しと同じく、キャッシュすることにして性能改善を行いました。

(ko1)

キーワード引数を渡すときに無駄なハッシュの複製をやめた

  • The number of hashes allocated when using a keyword splat in a method call has been reduced to a maximum of 1, and passing a keyword splat to a method that accepts specific keywords does not allocate a hash.

たとえばこういうコード。Ruby 2.7 では foo(**opt)の呼び出しでハッシュを 2 回複製していたのですが、3.0 では 1 回になりました。

deffoo(**opt)
end

opt = { a: 1 }
foo(**opt) # Ruby 2.7 ではこれでハッシュを 2 回複製していた、3.0 では 1 回になった

また、次のコードでは、複製回数が 1 回から 0 回に改善しました。

deffoo(a: 1)
end

opt = { a: 1 }
foo(**opt) # Ruby 2.7 ではこれでハッシュを 1 回複製していた、3.0 では 0 回になった

キーワード引数まわりで貢献しまくってくれた Jeremy らしい細やかな最適化です。

(mame)

JIT

  • Performance improvements of JIT-ed code
    • Microarchitectural optimizations
      • Native functions shared by multiple methods are deduplicated on JIT compaction.
      • Decrease code size of hot paths by some optimizations and partitioning cold paths.
    • Instance variables
      • Eliminate some redundant checks.
      • Skip checking a class and a object multiple times in a method when possible.
      • Optimize accesses in some core classes like Hash and their subclasses.
    • Method inlining support for some C methods
      • Kernel: #class, #frozen?
      • Integer: #-@, #~, #abs, #bit_length, #even?, #integer?, #magnitude, #odd?, #ord, #to_i, #to_int, #zero?
      • Struct: reader methods for 10th or later members
    • Constant references are inlined.
    • Always generate appropriate code for ==, nil?, and ! calls depending on a receiver class.
    • Reduce the number of PC accesses on branches and method returns.
    • Optimize C method calls a little.
  • Compilation process improvements
    • It does not keep temporary files in /tmp anymore.
    • Throttle GC and compaction of JIT-ed code.
    • Avoid GC-ing JIT-ed code when not necessary.
    • GC-ing JIT-ed code is executed in a background thread.
    • Reduce the number of locks between Ruby and JIT threads.

いろんな仕組みで JIT についての性能改善を行いました。詳細は今度開発者の国分さんが記事をかくらしいので、そちらをお待ちください。

(ko1)

■その他

そのほかの変更です。

ruby2_keywordが空のキーワード引数ハッシュを維持しなくなった

  • Methods using ruby2_keywords will no longer keep empty keyword splats, those are now removed just as they are for methods not using ruby2_keywords.

次のような挙動の違いが入りました。

ruby2_keywords defproxy_foo(*a)
  p a
end

proxy_foo(**{}) #=> [{}]  # 2.7
proxy_foo(**{}) #=> []    # 3.0

なぜこのような違いが必要になったは、すごくややこしいので、読み飛ばしてもらって大丈夫です。Ruby 2のキーワード引数がいかに壊れていたかが感じ取れるエピソードです。

素直な期待としては、**{}は何も指定していないのと同じ扱いであって欲しいです。しかしRuby 2では、**{}が「最後のハッシュの引数がキーワードでないことを示すためのトリック」として稀に必要になっていました。

# Ruby 2.7 での意味deffoo(opt = nil, k: "default")
  p [opt, k]
end# このメソッドのオプション引数 opt にハッシュ {k: "val"} を渡したい、どうする?# これはダメ
foo({k: "val"})       #=> [nil, "val"]             # キーワードとして解釈されてしまっている# これが正解
foo({k: "val"}, **{}) #=> [{:k=>"val"}, "default"] # opt にハッシュを渡せた

そして、このようなfooをターゲットとして委譲を行うproxy_fooというメソッドをruby2_keywords付きで宣言したケースを考えます。

ruby2_keywords defproxy_foo(*a)
  foo(*a)
end# 次のように動かないといけない
proxy_foo({k: "val"})       #=> [nil, "val"]
proxy_foo({k: "val"}, **{}) #=> [{:k=>"val"}, "default"]

つまり、proxy_foo**{}が渡されたかどうかを勝手に忘れるわけにはいかなかったということです。そのためにRuby 2.7では、呼び出し元で**{}がついているときに可変長引数の最後に空のハッシュを残すようになっていました。

さてRuby 3.0では、キーワード引数を渡したいときはfoo(k: "val")、ハッシュを普通の引数として渡したいときはfoo({ k: "val" })と書き分けることができるようになりました。よって、先のfooメソッドにオプション引数としてハッシュを渡したいときは、素直にfoo({ k: "val" })と書くだけで大丈夫です。

# Ruby 3.0 での意味deffoo(opt = nil, k: "default")
  p [opt, k]
end# このメソッドのオプション引数 opt にハッシュ {k: "val"} を渡したい、どうする?# 素直にこれだけでOK
foo({k: "val"}) #=> [{:k=>"val"}, "default"] # opt にハッシュを渡せた

これにより、**{}を使うトリックが不要になりました。よって、proxy_foo**{}が渡されたかどうかを覚えておく必要はなくなったので、簡潔にするために冒頭の変更がなされました。

(mame)

バックトレースの順序が再逆転

  • When an exception is caught in the default handler, the error message and backtrace are printed in order from the innermost. [Feature #8661]

バックトレースの順序はRuby 2.5で逆転したのですが、Ruby 3.0で再逆転しました(古い順に戻った)。

次のコードでのバックトレースを見ればわかると思います。

deffoo; raise; enddefbar; foo; end
bar

Ruby 2.7の出力。

が一番上。

$ ruby test.rb
Traceback (most recent call last):
        2: from test.rb:3:in`<main>'        1: from test.rb:2:in `bar'test.rb:1:in `foo': unhandled exception

Ruby 3.0の出力。

が一番下。

$ ruby test.rb
test.rb:1:in`foo': unhandled exception        from test.rb:2:in `bar'        from test.rb:3:in `<main>'

Ruby 2.5で逆転した動機は、バックトレースが長すぎるときに例外メッセージを見つけるために端末出力をスクロールしなければならないのがいやだったことでした。この問題を軽減するために、前述の --backtrace-limitが導入されました。

再逆転したのにはいくつか理由があります。

  • バックトレースの順序がツールや設定によってバラバラになってしまい、統一が進む様子もなかった ((仮にツールが対応してくれても、p *callerというコードで擬似的にバックトレースを出力させる技などがあり、これを逆転させるのは難しかった。))
  • 一部のRailsユーザから逆転させて欲しいという要望があって変わったが、本当に多くのRailsユーザが逆転を望んでいたのか怪しくなった
  • 「古い方の順に戻してほしい」という文句を3年間言い続けた人がいた(私です)

もし「Ruby 2.7の順序が本当に本当によかったのに!」という人がいたら、声を上げ続けるとよいと思います(流石に再々逆転はむずかしいと思いますが……)。

(mame)

未初期化インスタンス変数にアクセスしても警告が出ないようになった

  • Accessing an uninitialized instance variable no longer emits a warning in verbose mode. [Feature #17055]

未初期化のインスタンス変数を参照すると、-w 付きで実行していると警告が出てきてましたが、この警告が出なくなりました。挙動としては、単に nil が返ります。

$ ruby_2_7_ruby -we 'p @foo'
-e:1: warning: instance variable @foo not initialized
nil

$ ruby -we 'p @foo'
nil

この警告は、インスタンス変数名を typo に気づけるかも、ということで導入されていましたが、

  • この警告を排除するために、事前に初期化が必要で面倒
    • 書くのが面倒
    • 実行時に初期化コードが遅くなるのが面倒
  • そもそも、-w つきであんまり実行しないから、普段から気づかないよね

ということで、警告を出さなくなりました。そのため、initializeメソッドでの nil 初期化は、このためには不要になりました。

(ko1)

■おわりに

8 年ぶりにメジャーバージョンアップした Ruby 3.0 、年末年始のお休みにでも、ぜひ楽しんでみてください。

Ruby 3 では、静的検証や並行並列処理のサポートなど、大きな機能の導入がありました。 また、目標としていた Ruby 2.0 よりも3倍速い、Ruby 3x3 を JIT コンパイラの導入により達成しました。

Ruby はこれからも進化を続けていきます。ご期待ください!

では、ハッピーホリデー!

PS: 明日 12/26 (土) 13 時から、Ruby 3.0 のリリースについて、まつもとさんを交えて語るイベントを開催します(Ruby 3.0 release event - connpass)。もしよかったらご参加ください。

*1:また、後述する静的型解析のためにキーワード引数を扱いやすくしたいという狙いもありました。

*2:productionに投入できない、Rubyで書かれたツールを使っているだけの人に警告を見せても不安を煽るだけ、など。

*3:警告を止める方法は提供していたのですが、コミュケーションが不足していたり、より柔軟な警告除外指定が必要だったり、より簡単な方法が望ましかったり。

*4:将来のRails 7はRuby 3.0以降を要求する公算が高いので、Ruby 3.0で未分離、Ruby 3.1で分離、となると都合が悪い。

*5:遠藤の実力では def: foo(a) = expression というように def の後にコロンを必要とする文法しか実装できなかったのですが、bison を母語のように話せる nobu が一瞬でコロンなしで再実装してくれました。

*6:軽く調べたところ、少なくともruby-0.69(1995年頃)ではTRUEがあり、trueは未定義のようです。

Ruby 3.0 の Ractor を自慢したい

$
0
0

Ruby の開発をしている技術部の笹田です。娘が自転車に乗り始め、まだ不安なためずっとついていなければならず、少し追っかけまわしただけで息切れがヤバい感じになっています。運動しないと。

ここ数年、Ruby で並列処理を気軽に書くための仕組みである Ractor を Ruby 3.0 で導入するという仕事を、クックパッドでの主務として行ってきました(クックパッドから、これ、と言われていたわけではなく、Ruby を前進させるというミッションの上で行ってきました)。

Ractor は、もともと Guild という名前で開発をはじめ、2020年の春頃、Ractor という名前に変更することにしました。いくつかの機会で発表しています。下記は、RubyKaigi での発表の記録です。

そして、昨日リリースされた Ruby 3.0 で導入されました。やった! ただ、まだ仕様が変わりそうなことと、色々実装がこなれていないので、実験的機能として導入されており、使うと警告が出る状態です。

本稿では、Ractorの簡単なご紹介と、Ractor の(私の考える)位置づけ、そして将来の Ruby (主語が大きい)についてご紹介します。あまり how to な内容ではありません。

Ractor 自体の詳細は、ruby/ractor.md at master · ruby/rubyにあるのでご参考になさってください。また、先日の本ブログ記事 Ruby に Software Transactional Memory (STM) を入れようと思った話 - クックパッド開発者ブログにも、いくつか基本的な使い方が載っています。

簡単な Ractor の紹介

例を用いて、Ractor の機能と現状について簡単にご紹介します。

Ractor での並列処理で、実際に速くなる例

Ruby 3.0 のリリース文(Ruby 3.0.0 リリース)にある、Ractor プログラムの例を見てみましょう。ここ、私が書きました。引用します。

deftarai(x, y, z) =
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))
require'benchmark'Benchmark.bm do |x|
  # sequential version
  x.report('seq'){ 4.times{ tarai(14, 7, 0) } }

  # parallel version
  x.report('par'){
    4.times.map doRactor.new { tarai(14, 7, 0) }
    end.each(&:take)
  }
end

(1行 def と呼ばれる新機能を使っているのがオシャレポイントです。定義自体は4行だけど)

このプログラムでは、ベンチマークでよく用いられる竹内関数(竹内関数 - Wikipediatarai(14, 7, 0)を、4回実行するか(seq)、Ractor を用いて4並列実行するか(par)で、実行時間を測っています。

Ractor.new { tarai(14, 7, 0) }が、新しい Ractor で tarai()関数を実行する部分です。Thread.new{}のように、ブロックの部分を新しい Ractor(の中で作った Thread)で実行します。Ractor をまたいだスレッドは並列に実行されるので、この tarai()も並列に実行されるというわけです。

Ractor#takeによって、その Ractor が値を返すのを待つことができます。

さらに、結果をリリース文から引用します。

Benchmark result:
          user     system      total        real
seq  64.560736   0.001101  64.561837 ( 64.562194)
par  66.422010   0.015999  66.438009 ( 16.685797)

結果は Ubuntu 20.04, Intel(R) Core(TM) i7-6700 (4 cores, 8 hardware threads) で実行したものになります。逐次実行したときよりも、並列化によって3.87倍の高速化していることがわかります。

このマシン、笹田の自宅にあるマシンなんですが、ちゃんと4並列で4倍近い性能が出ていてよかったね、という結果になっています。こんな感じで、Ractor を用いることで、並列計算機上で並列処理を行うことができ、うまくいけば並列実行による速度向上が狙えます。

現状の Ractor

先ほどの例では、4倍近い高速化を達成することができました。ただ、これベストケースというか、チャンピオンデータというか、うまくいく例でして、多くの場合、Ractor 自体は、まだまだうまいこと性能が出せていません。

例えば、リリース直前に発見した、性能上の大きな問題。デモのために、あまり意味がありませんが、tarai関数の先頭で、Objectを参照してみましょう。

deftarai(x, y, z) = Object&&
  x <= y ? y : tarai(tarai(x-1, y, z),
                     tarai(y-1, z, x),
                     tarai(z-1, x, y))

必ず真になるので、不要な参照です。では、同じようにベンチマークをとってみましょう。

          user     system      total        real
seq  79.807530   0.000000  79.807530 ( 79.807818)
par 902.635763 432.107713 1334.743476 (343.626728)

なんと桁違い。4倍速いならぬ、4倍遅い、という残念な結果になってしまいました。なぜこんなことになってしまうかというと、定数(Object)の参照が遅いためです。

理由を少し解説すると、次のようになります。

  • (1) 定数参照時に利用するインラインキャッシュがスレッドセーフでなかったため、main Ractor 以外ではキャッシュを無効にしていた
  • (2) 定数参照時、定数テーブルは Ractor 間で共有するため、ロックを行うが、ロックが競合するとむっちゃ遅い

(1) と (2) の相乗効果でだいぶ遅くなってしまっています。残念無念。リリース直前に発覚したので、これから直そうと思っています(修正自体は、そんなに難しくない)。Ractor 自体は、こういうのがチョイチョイありそう、というクオリティーになっています。

これに限らず、これからいろんなフィードバック(主に苦情)を受けると思います。それらに対処していくことで、完成度をあげていこうと思っています。というわけで、「これおかしいんじゃないの?」とか、「ここが遅いんだけど」といったフィードバックを歓迎します。伸びしろしかないRactorを、一緒に育てていってください。

というわけで、まだそういうクオリティなので、Ractor.new{}すると警告が出ます。

warning: Ractor is experimental, and the behavior may change in future versions of Ruby! Also there are many implementation issues.

書いてある通り、仕様も fixed というわけではないので、変わるかもしれません。こちらもフィードバックをお待ちしております。

Ractor の基礎

Ractor の仕様は、下記のポイントを基礎としています。かいつまんでご紹介します。

  • Ractor.new{}で複数の Ractor を作ることができ、それらは並列の実行される
  • Ractor 間のオブジェクトの共有はだいたい禁止されている
    • 共有不可 (unshareable) オブジェクト
    • 特殊な共有可能 (shareable) オブジェクトだけ共有可能
      • Immutable オブジェクト
      • Class/Module オブジェクト
      • その他
  • 2種類のメッセージ交換方式
    • push型: r.send(obj) ->Ractor.receive
    • pull型: Ractor.yield(obj) ->r.take
r = Ractor.new doRactor.receive # :ok を受診し、それをブロックの返値とするend  

r.send(:ok) # r へ :ok を送る(push)
p r.take #=> r のブロックの返値 :ok を取得する(pull)
  • Ractor.selectによる同時待ち
r1 = Ractor.new{ :r1 }
r2 = Ractor.new{ :r2 }
r, msg = Ractor.select(r1, r2)
# どっちか早く終わったほうのメッセージが得られる
  • メッセージの送信方法
    • 複製: ディープコピーして送信
      • r.send(obj)
    • 移動: 浅いコピーを行うが、送信元ではそのオブジェクトを利用不可(使うと、どんなメソッドも method_missingになる) ruby r.send(obj, move: true) obj.inspect #=> `method_missing': can not send any methods # to a moved object (Ractor::MovedError)
      • 情報の世界では、自動的にコピーになることが多いので、「移動」という概念は面白いと思う(これが、Guild という言葉の由来だった)
  • 複数 Ractor を動かす場合、いくつかの機能に制限(後述)

詳細はドキュメント(ruby/ractor.md at master · ruby/ruby)、もしくはRuby に Software Transactional Memory (STM) を入れようと思った話 - クックパッド開発者ブログの冒頭の例などをご覧ください。

以降は、最近入って、まだあまり紹介されていない機能についてご紹介します。

Ractor#receive_ifによる選択的受信

Ractor.receiveは Ractor に送られたメッセージを、FIFO で取り出すという機能でした。ただし、これだと、複数の Ractor から順不同で送られてくるメッセージを、区別して扱うことができません。

Erlang/Elixir などの言語では、ここでパターンマッチを用います。

Processes - The Elixir programming languageから引用)

# elixir の例
iex>receivedo...>   {:hello, msg} -> msg
...>   {:world, _msg} ->"won't match"...>end"world"

この例では、receiveで、pat -> exprのように、patにマッチしたメッセージが見つかれば、exprを実行する、のように記述することができます。

Ractor.receiveで似たようなことをすると、マッチしなかったとき、incoming queueにメッセージを戻すことができないため、似たような機能を作ることができません(receive済みのメッセージをためておく仕組みと、そこから取り出す仕組みを作って、receiveは直接用いない、とすればできんこともないです)。

そこで、Ractor.receive_ifが(結構リリース直前に)導入されました。

Ractor.receive_if{|msg| /foo/ =~ msg}

この例では、受信したメッセージのうち、/foo/にマッチする場合、ブロックが true を返し、そのときはじめて incoming queue からメッセージを削除します。

この機能を用いることで、あるパターンに合致したメッセージのみ受信することができます。

ただ、Erlang/Elixir にあったような、パターンA なら処理A、パターンBなら処理B、というようなことは書けません。というのも、このブロックは述語として true/false を返すべきものであるからです。

無りやり書くとすると、こんな感じで Proc (labmda) を返し、それをブロックの外側で実行する、として記述することが可能です(break などでブロックを抜けると、true を返したときのように incoming queue からメッセージを抜きます)。そして、その後に実行したい処理を Proc で返しているので、それを呼べば対応する処理(taskA か taskB)を実行できる、というものです。

Ractor.receive_if do |msg|
  case msg
  when patA
    break -> { taskA(msg) }
  when patB
    break -> { taskB(msg) }
  endend.call

が、これも正直書きたくないので、Ruby 3.1 以降にマクロが入れば、なんかいい感じにできそうだなぁ、と考えています。

複数 Ractor を動かす場合、いくつかの機能に制限

これまで、Ractor がなければ問題なく使えてきた機能が、Ractor 間でのオブジェクトの共有を排除するため、複数 Ractor 環境において制限されました。Ractor を使わなければ(main Ractor だけで利用するなら)、これまで通り制限はありません。

具体的には、次の操作が main Ractor だけで利用可能になります。

  • (1) グローバル変数、クラス変数の設定・参照
  • (2) 定数に共有不可オブジェクトの設定・参照
  • (3) 共有可能オブジェクトのインスタンス変数の設定・参照(とくに、クラス・モジュールで問題になる)

どの機能も、使われていると一発で main Ractor 以外で利用できなくなります。この中で、一番はまりそうなのは、(2) と (3) でしょうか。

C = ["foo", "bar"]  # NG: (2) 定数に共有不可オブジェクトを設定classC@bar = 42# NG: (3) 共有可能オブジェクトのインスタンス変数を設定defself.bar@bar# NG: (3) 共有可能オブジェクトのインスタンス変数を参照enddefself.bar=(obj)
    @bar = obj  # NG: (3) 共有可能オブジェクトのインスタンス変数を設定endend

よく使われていそうなプログラムです。この制限により、多くのライブラリを、複数 Ractor 上で利用することが、現在できません。今後、うまいこと書き換えが進むと、Ractor は利用しやすいものになっていくと思います。

さて、ではどのようにすればいいでしょうか。

(2) については、# shareable_constant_value: ...というプラグマが新設されました。

# shareable_constant_value: literalC = ["foo", "bar"]

このオプションで none(デフォルトのモード)以外を選ぶと、定数が共有可能オブジェクトを参照している、ということを保証できます。この例では、literalを選んでいます。これは、定数の右辺値、つまり代入するオブジェクトがリテラルのように記述されたオブジェクトなら、再帰的にfreezeしていくことで、immutable な共有可能オブジェクトを生成し、定数に代入します。リテラルでなければ、共有可能であるか実行時にチェックすることで、共有不可オブジェクトが定数に代入されることを防ぎます。

指定できるオプションは、noneliteral以外に、この2つが指定できます。

  • experimental_everything
    • 右辺値の値を共有可能オブジェクトに変換する
  • experimental_copy
    • 右辺値の値をまずコピーし、コピーに対して共有可能オブジェクトに変換処理を行う

everythingは副作用が気になりますが、copyは元のオブジェクトに影響を与えないため、副作用がほぼ起こりません。ただし、コピーによって若干時間がかかるかもしれません。

(3) の、共有したい mutable な値については、gem になりますが、Ractor::TVarractor-tvar | RubyGems.org)を用いると良いと思っています。

classCBAR = Ractor::TVar.new 42defself.barRactor::atomcally{ BAR.value }
  enddefself.bar=objRactor::atomcally{ BAR.value = obj }
  endend

Ractor::atomcallyを毎回書かないといけないのは冗長な気もしますが、ここで Ractor 間にまたがる共有状態を操作している、というのが明示できて、長い目で見ると利点になるのではないかと思っています。

拡張ライブラリの Ractor 対応

C などで記述された拡張ライブラリは、デフォルトでは main-Ractor 以外では動きません(提供されているメソッドを呼ぼうとすると、Ractor::UnsafeErrorになります)。

対応させるためには、複数 Ractor で実行していいことを確認して、rb_ext_ractor_safe(true)で、この拡張ライブラリが Ractor のサポートをしていることをインタプリタに教えてあげることが必要です。

対応させるためのチェックポイントについて、詳細は、ruby/extension.rdoc at master · ruby/rubyにまとめてあります。ただ、あんまり変なことしてなければ、たいてい Ractor 対応は簡単じゃないかなと思っています。

Ractor の背景

ここからは、具体的なコードの話ではなく、Ractor に関する検討について、その一端をご紹介します。

複数コアのCPUが普通になってきた昨今、並列計算を記述する、というニーズはどんどん高まっています。というフレーズは、私が大学で研究していた10年以上前から定番の前振りでした。実際、高性能なソフトウェアを書くのに、並列計算は必須であることにどなたも異論はないでしょう。

並列計算を行うためには、プログラムが並列計算に対応していなければなりません。そのためには、並列プログラミングが必要になります。すでに、多くのプログラミング言語が並列計算のための仕組みを備えています。

スレッドプログラミングは難しい

ただ、並列計算を行うプログラムは、だいぶ面倒くさいことが知られています。とくに、スレッドプログラミングは、いろいろな理由から、正しいプログラムを書くことが困難です。Ruby でも、スレッドは Thread.new{ ... }と書くことで、簡単に作ることができます。

たとえば、同じメモリ領域に、複数のスレッドが同時に読み書きすると、おかしなことになります。「この順番で読み書きしているから大丈夫」とすごく考えてプログラムをかいても、コンパイラの最適化によって読み書きの順序が変わったりして、逐次処理しているときには気づかなかった問題が生じることも多いです。この問題を、よくスレッド安全の問題といいます。

デバッグ時には、非決定的(non-deterministic)な挙動が問題になります。逐次処理は、2度実行すれば、だいたい同じ結果になります(そういう筋の良いバグが多いです)。しかし、複数のスレッドがどのように動くかは、スレッドマネージャの仕事になり、一般的には制御することは難しく、2度目の実行では異なる結果になることが多いです。そのため、問題が発覚しても、その問題を再現することが難しく、つまりデバッグがすごくしんどいわけです。

この非決定性は、ほんとうにタイミングよく何かしないと起きないバグなんかだと、めったに再現しないので、がんばって修正をしても、本当にその問題が解決したのかわからない、といった話もあります。

メモリを共有した同時の読み書きは難しい

スレッドプログラミングの1つの問題点は、複数スレッドでメモリを同時に読み書きが可能である、という点が挙げられます。同時に読み書きが起こる可能性があるメモリ領域においては、ロックをかけて排他制御するなど、他のスレッドと同期しながら処理を進める必要があります。

が、往々にして、こういう「ちゃんとアクセスする前にはロックをとる」みたいなものは、忘れがちです。人間は、ウッカリをするものです。私はしょっちゅう忘れて痛い目にあっています。

「私は大丈夫、ちゃんと同期とか仕込める」という人も、うっかりやっちゃう可能性はいくらでもあります。いくつか、うっかりしそうな例を並べてみます。

  • プログラムの規模が大きくなり、想定と別の用途でデータを用いて、うっかりロックが必要であることを忘れる
  • データ構造が複雑化し、共有されていることに気づかず、うっかりロックを忘れる
  • 別の人(将来の自分かも)がうっかりロックを忘れてアクセスする

他にもいろいろあると思います。

ちなみに、「ちゃんと動くプログラムを書く」というのも難しいですが、さらに「速いプログラムを書く」というのも難しい問題です。例えば、異なるメモリには、異なるロックを本当に必要な時にだけ用いたほうが(つまり、細粒度ロックを用いるほうが)並列度はあがり、並列処理の性能向上をますが、ロックの処理(獲得と開放)を頻繁に行う必要が出てきて、下手に作ると遅くなってしまいます。

難しさに対する対応策

もちろん人類は賢いので、様々な対策を考えてきました。

  • ロックなどをきちんと使っているか、チェックするツールの利用(valgrin/helgrind、thread-sanitizer、...)
  • ロックなどを自然に使うことができるデータ構造の導入(同期キュー、Transactional memory、...)
  • 型によるデータの所有系の明示(Rustなど)
  • 書き込みを禁止して、同時に読み書きを起こさない(Erlang/Elixir, Concurent-haskell など)
  • そもそもプロセスなどで分離して、共有しない(shell, make)

が、どれも完全に解決するのが難しいか、Ruby に導入するのは困難です(個人の見解です。別の見方もあると思います)。

  • ツールは漏れが生じます。また、MRI の構成上、(現実的なコストで)実現がなかなか困難です
  • データ構造を正しく扱えば問題なくても、ロックを忘れるのと同様にうっかり正しくない使い方をしてしまいます
  • Ruby にはこの手の型を記述する方法がないため困難です(文法を入れるのはきっと難しい)
  • 書き込み禁止(例えば、インスタンス変数への代入禁止)は、互換性を大いに壊します

最後の「そもそもプロセスなどで分けて、原則状態を共有しない」という shell などで利用されているアプローチは、Ruby でもマルチプロセスプログラミングとしてすでに行われています。dRubyやUnicorn、paralle.gem のプロセスモードなどがこれですね。通信する場合にひと手間かける、というアプローチになっています。

このモデルでは、それぞれのコンポーネントを単純に作ることができ、まさに UNIX 流の開発の利点が効いてきます。パイプなどでうまくつなげることで、それぞれが独立に並列実行させることができたり、make で依存関係のルールを記述することで、それぞれのタスクを良い感じに並列実行させることができます。また、別の計算機に処理を分散させることも、比較的容易です。

ただ、プロセスを複数いい感じに並べるだけだと、パイプだけだとちょっと表現力が弱く(パイプライン並列処理に特化している)、make も、あまり複雑なことは書けません。先述した Unicorn なども、あるパターンに特化していますね。

それから、コミュニケーションを主にパイプで行うため、通信のための手間が、複雑なコミュニケーションを行う場合は結構大変になります。また、実行単位がプロセスになることが多いので、タスクが多い場合、リソース消費が問題になる可能性があります。

Ractor の狙い

Rubyのモットーは「たのしいプログラミング」というところだと思います。できるだけ、難しいこと、面倒なことはしなくても良いようにするといいと思っています。

(現在、Ruby でも行うことができる)スレッドプログラミングは、その点で考えることがたくさんで、いざバグが入ると直すのが難しいという、「たのしさ」からは離れた機能ではないかと思うようになりました(難しいスレッドプログラミングをきちんとやる「たのしさ」もあると思うので、まぁ一概には言えないのですが)。この話は、手動メモリ管理と自動メモリ管理の話に似ていると思っています。つまり、ちゃんと作れば手動メモリ管理は効率的だったりしますが、うっかり間違えてしまったり、バグの発見は難しいし、というような。

そのため、多少性能を犠牲にしても(最高性能は出ないにしても)、なんとなく書けばちゃんと並列に動く、というのを目指すと良いのではないかと思い、Ractor を設計しています。

前節で述べた並列並行プログラミングの問題点を解決するために、Ractor はどのようなアプローチをとっているかご紹介します。

共有しない、がちょっと共有する

並列プログラミング言語において、あるメモリに対する read/write を混ぜない、というのは大事な観点であることをご紹介しました。並行並列に実行する処理が、共有状態を持たないと、問題が簡単にいなるわけです。

そこで、Ruby でこれを実現するのが Ractor です。Ractor という単位でオブジェクト空間を「だいたい」分けて、お互いに干渉させないようにさせます。これで、いわゆる同期漏れによるスレッド安全の問題が、だいぶ解決されます。

ただし、全部分けるとプロセスと同じでそれはそれで不便となるので、いくらか共有してもだいたい大丈夫だろうと思われるものを共有します。これが、プロセスで完全に分離してしまうことに対する利点になります。

この、ちょっとだけ共有することで、下記の利点が生じます。

  • Ractor 間の通信が、少し書きやすくなる
  • Ractor 間の通信が、少し速くなる
  • Ractor 間でメモリを共有することで、メモリ消費が減る

ウェブアプリケーションサーバにおいて、スレッドモデルが好まれるのが、「メモリ消費が減る」ではないでしょうか。Ractorでは、そのへんをそこそこ狙っています。

ただし、ちょっと共有することで、スレッド安全に関する問題が残ります。これは、本当に難しい問題で、利点を取るか欠点を取るか、ずいぶん悩んだのですが、今回は利点を優先することにしました。「まぁ、だいたい大丈夫だろう」というやつです。

ほかの言語では、Racket という言語で place という、Ractor と似た isolation を行う仕組みがあります(Places: adding message-passing parallelism to racket | Proceedings of the 7th symposium on Dynamic languages)。Ractor とよく似ていますが(だいぶ参考にしました)、通信の方法が、Go 言語のように、チャンネルを用いるというのが Ractor と異なります。

Actor model と CSP (Communicating Sequential Processes)

よく Erlang と Go の比較で、前者が Actor、後者が CSP を採用している、みたいな話があります。大雑把に言うと、前者が通信対象を並行実行単位(アクター)に、後者を並行実行単位をつなぐチャンネルに対して行うのが特長になるかと思います(厳密には多分違うと思うんですが、ここではそうとらえてみます)。

Ractor は、名前の通り Actor model を強く意識して設計されています。実は、2016年の開発当初では、とくに何も考えずに CSP 的なモデルを考えていました。ただ、数年色々考えた結果、Actor model のほうがいいかな、と思って、現在の設計になっています。

Actor model の利点はスケールがしやすいことと言われています。これ、作ってみるとわかるんですが、待ちが生じるのが、いわゆるアクターへ送られたメッセージを受信する操作に限定されるんですよね。複数のチャンネルを待ったりするより、自分自身に送られてきたメッセージを監視するだけのほうが楽なのです。他にも、コンポーネントを疎にしやすい(例えば、アクターが別の計算機にいてもよい)といった良い性質を持ちます。

が、あまりそのへんが Actor model 型のインターフェースにした理由ではなく、例外の伝搬を適切に行うことができるか、という観点から、現在のデザインにしました。

相手を指定する操作において(具体的には、Ractor#sendによる送信時に、もしくは Ractor#takeによる受信時)、相手の Ractor がすでに例外などで終了していた場合、エラーで気づくことができます。つまり、エラーが出ていることを、Ractor 間で適切に伝搬させることができるわけです。

CPSでは、処理の対象がチャンネルなので、その先につながっている並行実行単位の状況はわかりません(そもそもつながっていないかもしれない)。適切にチャンネルをクローズする、という手もありますが、ひと手間かかります(つまり、一手間を忘れる可能性があり、そして可能性があれば人は忘れる)。ソケットなんかは似たようなモデルですが、プロセスに紐づいているので相手側に状況が伝わります。こういうモデルでもよかったかなと思うのですが、うまいこと簡単に扱うAPIに落とし込めませんでした(チャンネルをさらにほかのRactorに渡すような用途で、うまいことモデリングできませんでした)。

いくつかのパターンでは、CPS のほうが書きやすい、というのがわかっていたのですが、Ractor 自体をチャンネルのように使えば、性能を気にしなければ、実は CPS とほぼ同じようなことができることがわかったので、とりあえず Actor model 風のインターフェースをベースにしました。性能はあとでなんとかしよう、と思っています。Actor っぽい push 型のコミュニケーション手段と、Actor っぽくない pull 型のコミュニケーション手段が混ざっているのは、この辺を作りやすくするためです。

コピーと移動

互いに分離された環境で通信するとき、コピーによってメッセージを渡すのは、よくある方法です。ただ、それだけだと他と同じで面白くないな、と思って考え付いたのが移動です。

情報の分野において、送ったメッセージが、その後参照できなくなるとういうのは、あまり聞いたことがないので面白いなぁと思って、Guild といってた時の目玉機能と思っていました。そもそも、Guild という名前は、Guild のメンバーが移籍する(moveする)という意図で見つけた名前でありました。

が、まぁ普段は使わない(コピーで十分)ということで、あまり前面に出さないようにして、そうすると Guild という名前もなんだね、ということで、Ractor に改名されました。

Ractor が使えるようになるまでの、まだまだ長い道のり

このように、とりあえず入った Ractor ですが、便利に利用するにはいくつかのハードルがあります。

利用者としての課題

まずは、ライブラリの対応がたくさん必要になります。とくに、先に述べた2点

  • (2) 定数に共有不可オブジェクトの設定・参照
  • (3) 共有可能オブジェクトのインスタンス変数の設定・参照(とくに、クラス・モジュールで問題になる)

については、だいぶ書き換えが必要になると思います。本当に大丈夫か、ってくらい。あと、説明してませんでしたが、define_methodによるメソッド定義で使うブロックが、ふつうのブロックだと他の Ractor では使えないというのがあります。まずそうな点です。

ライブラリがないと Ruby の魅力はものすごく下がってしまいます。そのため、これらの変更に追従していただけるかどうかが、Ractor が成功するかどうかの分水嶺になるかと思います。

使いづらいところがあれば、Ractor 側で改良したり、便利なライブラリを提供していったりしていきたいと思います。フィードバックをお待ちしております。

書き換えはいろいろ面倒なのですが、これは、スレッド安全を解決するための、見直すための良い指針の一つになる可能性があります。いままで、スレッド安全について、テストで問題ないし、なんとなく平気かな、と思っていたところが、ぜったい大丈夫、という安心感に代わるんではないかと思います。

並行並列処理時代の Ruby に書き換えるという、個人的には Ruby の性質を変える話じゃないかと思います。

実装上の課題

最初にご紹介した通り、性能上の問題、そしてバグが残っています。随時直していこうと思いますので、こちらもフィードバック頂ければと思います。

おわりに

本稿では Ractor についてご紹介しました。

自慢したいことは、まだ仕様・実装ともに不十分ではありますが、Ractor を導入までもっていったこと、それから娘が自転車に乗れることです。

新しい Ruby の一つの形ということで、楽しんでいただければ幸いです。

では、よいお年をお迎えください。

データ基盤チーム0人で運用は回るのか?! 前人未踏チャレンジ・クックパッドデータ基盤のすべて2020

$
0
0

技術部データ基盤グループの青木です。

ここ1、2年はなぜか成り行きでBFFをでっちあげたり、 成り行きでiOSアプリリニューアルのPMをしたりしていたので あまりデータ基盤の仕事をしていなかったのですが、 今年は久しぶりに本業に戻れたのでその話をします。

突然の1人チーム、そして0人へ……

今年のデータ基盤チームは消滅の危機から始まりました。

間違いなく去年末は5人のチームだったと思うのですが、 メンバーがイギリスへグローバルのデータ基盤チームを作りに行ったり、 山へ検索システムを直しに行ったり、川へレシピ事業の分析業務をやりに行ったり、 海へ広告のエンジニアリングをしに行ったりするのをホイホイと気前よく全部聞いていたら、 なんと4月から1人だけのチームになってしまいました。

事はそれで終わりません。 恐ろしいことに10月にはわたし自身も育休に入ることになったので、 10月はデータ基盤が0人になることが決まりました。

えっ……マジで……? ヤバない……?

もちろん大変ヤバいです。そんなわけで今年は徹底的な運用改善、 できれば完全無人運用が可能なシステムが最優先目標になりました。

アーキテクチャの概要

まずは前提として、クックパッドのデータ基盤アーキテクチャをざっくり説明しておきます。

f:id:mineroaoki:20201229002438p:plain
クックパッドのデータ基盤アーキテクチャ

中心とするデータベースはAmazon Redshiftです。 2016年から同じサイズのクラスターを使い続けています。

データインポートはマスター、ログ、それ以外の3系統。 各種アプリケーションのマスターテーブルは内製のPipelined Migratorまたは AWS DMS(Data Migration Service)で取り込んでいます。 MySQLがmigrator、PostgreSQLがDMSという使い分けです。 ログにはRedshift Spectrumを使っており、Spectrumへのロードにはこれまた内製の Prismというシステムを使っています。 それ以外のSaaSやDynamoDBのデータについては、アドホックなバッチジョブを Bricolageフレームワークで作ってロードしています。

Redshift内での処理はBricolageを使ったSQLバッチが大半です。 ごく一部はUDFを使ったり他システムへ処理を投げたりしていますが、 9割以上はpure SQLで処理しています。

一方のデータエクスポートも3系統あります。 管理画面などの社内アプリケーション、BIツール(社内標準はTableau)、 それに他システムへのバルクエクスポートです。

バルクエクスポートについてのみ詳細を説明すると、 基本的にはQueuery(きゅーり)という内製のシステムを使っています。 QueueryはHTTPのAPIでRedshiftにクエリーを投げられる薄いシステムで、 内部ではRedshiftのUNLOADを使っています。 アプリケーションはUNLOADされたデータをS3から読むので、 読み込みの負荷をRedshiftから切り離すことができる利点があります。

特にRubyからは、redshift-connectorというライブラリで Queueryを簡単に使えるようにしています。

2020年に行った施策

以上がデータ基盤の概要です。

アーキテクチャは最初に設計した2016年からほとんど変わっていませんが、 5年たったので細部の実装はいろいろと変わってきています。 2020年はさきほど述べたように運用改善が最優先だったので、 そのあたりを中心に対応しました。以下の5本立てでお送りします。

  1. Redshift Spectrumへの移行が(だいたい)完了
  2. Prismの運用改善
  3. ログ定義からのクライアント自動生成
  4. Redshiftのワークロード管理機能の活用
  5. Tableau運用フローの改善

1. Redshift Spectrumへの移行が(だいたい)完了

今年最大の成果はなんと言ってもSpectrum化が「だいたい」終わったことです。

Spectrum化作業はDWHチームのべ5人で交代しながらチマチマやっていたせいで、実に丸3年かかりました。 Redshiftの内部ディスクにあったログテーブルを300本近く捨てたことで、 クラスターのディスク容量は50%を切りました。これまでは常時カツカツで、 80%を越えるたびに過去のデータを消しては凌いでいたことを思うと隔世の感があります。

実際にやったことは1000本近いバッチジョブをひたすら書き換えるだけの簡単なお仕事です。 書き換えては数値検証、書き換えては数値検証で、検証の時間が一番長かったですね。 横長スプレッドシートと仲良くなれます。

また、移行が完了したことで、内部テーブルにロードするために使っていた 旧システム(strload v2, v3)を捨てられるようになりました。 これでようやくロードシステム3系統をメンテする地獄から解放されます。

2. Prismの運用改善

Spectrum化を進めていくうえで大きな課題になってきたのがPrismの運用のつらさです。

今年はコロナの影響などもあって、3月〜4月ごろにやたらとログの流量が増えており、 しょっちゅうPrismマージジョブのメモリが溢れて死に続ける事故が起きていました。 しかし、その当時のPrismはモニタリングするにもDBを直接見るしかなく、 ジョブのログテーブルもなかったので、ジョブが死んでも何の処理中に死んだのかもよくわからない有様……。

これではさすがにやってられないので、 Prismの前に使っていたstrloadというロードシステムの管理画面を流用し、 2日くらいでコンソールをでっちあげました。

f:id:mineroaoki:20201229002600p:plain
Prismの管理画面

もっとも、管理画面を作ったところで現状が見えるようになっただけにすぎません。 根本的に問題を解決した施策は、遅延ログの扱いを変更したことでした。

これまでPrismはどんなに遅れて到着したログもすべて受け入れて既存パーティションへ マージしていたのですが、今年からはそれを14日で捨てるように変えました。 これはBigQueryもそういう仕様ですし、問題はなかろうということである日突然えいやっと切り替えました。

この点は開発前にはよくわかっていなかったところの1つなのですが、 遅れたログを永久に受け入れていると、ロードシステムの負荷が非常に大きいのです。

例えばプッシュ通知を配信したときに、その処理のためにアプリがバックグラウンドで動く場合があります。 すると端末側のログバッファがいっせいにフラッシュされ、 しばらく休眠していたユーザーも含めて過去のログがまとめて到着します。 すると結果として「プッシュ通知を送るたびに全ログ全期間をマージしなおす」という事態に陥ってしまうわけです。 これは負荷の面でも、コストの面でもさすがに看過できません。

遅延ログを14日で切るようにしたらPrismマージジョブの数が激減して(下図)、 いきなりすべてが安定しました。

f:id:mineroaoki:20201229002627p:plain
6/15から山岳地帯がサバンナに激変

ちなみに、Prismもだいぶ安定してきたので、来年は残りの懸念を潰してオープンソース化するつもりです。

3. ログ定義からのクライアントコード自動生成

今年はiOSアプリのリニューアルという大きな動きがあったので、 そのどさくさに紛れて新しいログの仕組み、通称「大統一アクティビティログ」を導入してもらいました。 この仕組みを使うと、特定のMarkdown形式でログのイベントを定義しておくことで クライアントのロガーとログ定義が自動生成されて、型のズレを根絶することができます。 詳しくは id:giginetの記事「ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築」を参照してください。

この仕組みは本当によくできていて、リリース以前にログをちゃんと考える契機になるうえ、 自動的にログのドキュメントが整備されるようになっています。 さらに自動生成システムはAndroidアプリやウェブでもそのまま再利用できたため、 コスパも非常によかったです。

データ基盤側の視点では、この仕組みを導入したことによって、 ログの型が事前に決まるようになった点が最大の利点でしょう。

これまではまず最初にアプリでログ出力が実装されて、 実際にログが届き始めてからログの定義(型)をもらい、 両者のズレをなんとかするというフローでログを運用していました。 しかし当然ながらこの順序では、想定通りのログが出ていなかったり、 データ基盤に設定するログ定義を間違えてしまうことが頻繁に起きます。 するとデータ基盤側でもデータを入れ直すタスクが発生して、 そのたびにいちいち手作業で対応していたわけです。 大統一ログの導入後はこのようなミスマッチも手作業も、いっさいなくなりました。

さて、定義が自動生成されるようになったので、このさい設定の適用も自動化しようということで、 ログ定義をGitHubにコミットしたら自動的に定義を本番適用するツールを開発しました。 このツールができたことによって、ログを追加するには次の3ステップで済むようになりました。

  1. 特定のMarkdown形式でログのイベントを定義する
  2. クライアントのロガーとログ定義を自動生成する
  3. ログ定義を専用のレポジトリにPull Requestしてマージする

このステップはすべて各アプリケーションの開発者が自分で行うことができます。

実はログ定義の本番適用とその後のフォローは、 データ基盤で発生する定期作業の中でも最も頻度が高い作業でした。 この作業を自動化できたことがデータ基盤0人期間を乗り切るための決定打となってくれました。

4. Redshiftのワークロード管理機能の活用

Redshift上のワークロードに関しては、 Concurrency ScalingとUsage Limit、それにAutoWLMを有効にして、 日々襲来する負荷の波をやりすごすことに取り組みました。

Concurrency Scalingは、Redshiftのread onlyクラスターを一時的に増やして、 クエリーの処理キャパシティを上げる機能です。これには当然ながら(?)お金がかかるのですが、 1日1時間の無料枠があるため、1時間だけスケールさせておけば無料で使えます。 そして1時間でスケールを止めるためにUsage Limitを使います。

f:id:mineroaoki:20201229002652p:plain
Concurrency ScalingとUsage Limit

Concurrency ScalingとUsage Limitについてはチームメンバーが書いた記事(英語ですが)があるので、 詳細を読みたいかたはぜひそちらをご参照ください。 結果だけざっくり言うと、コミット待ちの時間が約15%減りました。

最後のAutoWLMはこれまでのManual WLMと違い、CPUとI/Oも配分できるところが特徴です。 Manual WLMではせいぜいメモリしか配分できなかったので、ようやく普通のWLMになったなという印象です。

AutoWLMの設定にはあまりこっていません。 短かそうなクエリーにはリソースを多めに与えて速攻で終わらせる、 長い時間動いているクエリーは徐々にペナルティを増やしてリソース割り当てを減らす、この2つだけです。

様々なクエリーが混在する混合ワークロード環境では、短かいクエリーを早く終わらせることが肝要です。 何も知らないとついうっかり重いクエリーにリソースを割り当てたくなるのですが、実はそれが最もよくありません。 重いクエリーにリソースを割り当てると、大量のリソースがずっと占有されることになり、結果としてすべてのクエリーが詰まります。 むしろ短いクエリーに多すぎるくらいにリソースを割り当てて、とっとと次のクエリーが入る場所を空けさせたほうがパフォーマンスは上がります。

もっともAutoWLMを適用した効果は正直よくわからず、数値では明確に出せませんでした。 体感だとなんとなく待たされることが減っている気がしますが、プラシーボかもしれません。

5. Tableau運用フローの改善

いまのところ、クックパッドでは次のようにTableauワークブックの標準運用を定めています。

  1. 最初はカスタムクエリー(ワークブック埋め込みのSQL)を使って手軽に作る。データソースは抽出にする。
  2. カスタムクエリーをRedshiftのビューに変換する。
  3. ビューが重くなったら蓄積バッチ化する。

Tableauのカスタムクエリーは、作るときには簡単ではあるものの、 一度Serverにアップロードしてしまうと、ワークブックをダウンロードして 開かないと見ることができません。またそのときにRedshiftユーザー名を データソースの個数だけ要求されたりするので使い勝手が最悪に近いです。 できるだけ早くビューにしてしまって、 Tableauワークブックではビューをselectするだけにすべきでしょう。

しかしビューにするためにも結局カスタムクエリーを見る必要があり、 そのためにまたワークブックをダウンロードして20回ユーザー名を入力しなければいけないわけです。 これはあまりにもアホくさいですし、誰もやってくれないので、 カスタムクエリーをS3にダンプする日次バッチを作りました。

またRedshiftのビューを更新するにはバッチユーザーの権限が必要なので、 手作業でやるとわたしがボトルネックになりますし、 手更新はチームでのレビューがやりにくいという問題もあります。 そこで、ビュー定義をGitHubにコミットしたら自動的にビューを作成・更新する仕組みを作りました。 これはほぼ同じ事例をネットで見かけたので、やはりどこも同じことを考えるものですね。

結果、0人期間は乗り越えられたのか?

以上が今年やってきたことです。 ほぼ全方位にわたってとにかく手作業を減らし、そもそも定期的なメンテ作業が発生しないようにすること、 開発者にセルフサービスで問題を解決してもらえるようにすることに注力しました。

結論を言えば、データ基盤0人期間はなんとか乗り切ることができました。 実際には2、3回ちょっとした問題が起きたのですが、 元データ基盤のメンバーたちが首尾よく解決してくれたのでノーカンです。 完全無風とはいきませんでしたが、乗り切れたのでよしとしましょう。 元メンバーのみんなには感謝です! 今度おごります。

アーキテクチャ選定で後悔していることと、していないこと

ところで、この年末でクックパッドのデータ基盤は開発開始からほぼ丸5年を迎えます。 この節目に、アーキテクチャの選択について後悔していることと、していないことを総括したいと思います。

後悔していないこと

まず、Redshiftを選んだことは後悔していません。

一時期はBigQueryがうらやましすぎて、 他社のデータ基盤の人にBigQueryの話を聞くたびに 「ソーダヨネービッグクエリーベンリダヨネースゴイスゴーイ」 を無表情で連呼する機械と化していましたが、 まあいまでもうらやましい点もあるんですが、 トータルで見れば現状は悪くないなと思うようになりました。 あと5年戦って10年までいけそうな気がしています。

第一に、クックパッド全社のアーキテクチャを見たときにRedshiftは最もシンプルかつ安価なソリューションです。 やはりアプリケーションとデータ基盤をAWSで統一できるという点は非常に大きいと思います。 人間が分析をするだけならばデータ基盤を外出しにしてもたいして問題はないと考えていますが、 他システムとのやりとりが増えてくると、認証の複雑化なども含むデータ移動のコストがばかになりません。 これからますますデータ基盤と他システムとのデータ連携パスが増える一方であることを考えると、 アプリケーションとデータはできるかぎり近くの、連携が容易な場所に置くべきでしょう。

第二に、SpectrumやPartiQLによって、 ログをRedshiftで扱いやすくなったことが挙げられます。 もし仮にこれらの新機能がないままだったら、さすがに後悔していたでしょう。 Redshiftは次々に新機能がリリースされるので、いま困っていることでも 少し待っていたらどうにかなるのではないかという謎の安心感があります。

最後に、Redshift Federated Queryの存在が挙げられます。 Federated QueryはアプリケーションのDB(MySQLやPostgreSQL)に Redshiftから直接接続してクエリーすることができる機能です。 これはAWSでシステムを統一してこそ活用できる機能なので、 クックパッドにとってはまさに狙い通り、待望の機能でした。 今年はPostgreSQL限定だったので試験運用にとどまりましたが、 来年はいよいよMySQLサポートがやってくるので、大々的に使っていくつもりです。

来年はRA3ノードも導入する予定ですし、まだまだRedshift周辺は楽しめそうです。

後悔していること

逆に最も後悔した選択はTableauです。

この記事でもTableauのカスタムクエリーをダンプする仕組みなどについて述べましたが、 そもそもこれはTableauのダメなところをカバーする仕組みであって、 こんなロクでもない機能を実装しなければいけない時点でもうダメです。

運用面では、共有に向いていないデータソースの仕組みと、 抽出(extract)更新の管理機能が弱すぎる点が癌です。 利用者側から見るとコラボレーションと共有の機能が貧弱すぎます。 総じてTableau Serverの機能不足が目立ちますね。

ちなみに、以前に利用していたRedashは手軽さは最高によかったのですが、 データ更新ジョブの実行ログが貧弱である(というかない)こと、 クエリーの並列実行でキューが壊れまくることが課題でした。 いったい何回Redis(クエリー実行キューがある)をflushdbしたかわかりません。

データ基盤チームは仲間を募集しています

さすがに1人チームは無理があるということがわかったので、来年からはメンバーが1人増えることになっています。 もう1人くらいは社内から増やせそうな気がしますが、 できればさらにもう1人ほしいので、データ基盤チームでは仲間を大募集しています。 データ基盤を整備するどさくさに紛れて新しいシステムを開発したい人はぜひご応募ください。 以下のページの「データエンジニア」がデータ基盤チームです。

クックパッド採用情報 https://info.cookpad.com/careers/jobs/?jobs=engineer

カスタムなSF SymbolsをSVGから自動生成する

$
0
0

(English version here)

明けましておめでとうございます。モバイル基盤部のヴァンサン(@vincentisambart)です。

最近Appleがアプリの画面で使えるシンボルSF Symbolsに力を入れています。SF SymbolsはAppleの用意してくれたシンボルだけではなく、自分の作ったカスタムシンボルも使えます。Appleの紹介しているカスタムシンボルを作るワークフローに従うと手間がかかるので、既存のSVGからカスタムシンボルを自動生成できないか挑戦してみました。

経緯

だいぶ前からiOSクックパッドアプリで色んな画面で使われている単色アイコンはCookpadSymbolsというシンボルのみのフォントが使われていましたが、数ヶ月前デザイナーからシンボルの運用をフォントファイルからSVGに変えたいという要望が挙がりました。

アイコンは元々SVGで作成されていましたが、変更を加える度にSVGや設定ファイルをウェブ上のツールに読み込ませてフォントを生成するステップを省きたかったそうです。今となってはSVGを直接使えるようになった場面が多いですし。

CookpadSymbolsはこんな感じです。

f:id:vincentisambart:20201228112920p:plain

iOSでは、SVGとして用意されたシンボルを使うには以下の3つの方法があるかと思います。

  1. サイズの決まったピクセル画像として使う(実質PNGに変換されたかのように)
  2. ベクターデータのまま画像として使う(Asset CatalogのPreserve Vector Data設定)
  3. Xcode 12以上で直接SVGを使えるようになりましたが、iOS 12以下でベクターデータとして扱うにはSVGを事前にPDFに変換する必要があるようです。
  4. カスタムシンボルとして使う(カスタムシンボルは簡単に言いますと自分で用意したカスタムなSF Symbolsのことです)
  5. iOS 13以上が必要です。

iOSクックパッドアプリでは、シンボルは今までフォント形式で扱っていて、同じシンボルは画面によって違うサイズで表示されるので、固定サイズ画像として扱うとなるとだいぶ不便になります。元がベクター画像なので簡単に様々なサイズを自動的に用意できるとはいえ。

最近AppleがSF Symbolsを大きくプッシュしているようですし、デザイナーの要望が挙がった当時すぐiOS 12のサポートを終了する予定だったので、方法3でやってみることにしました。もしもiOS 12のサポート終了が大幅に遅れる場合や、実装している途中で大きい問題が発生した場合、最悪方法2にフォールバックすれば良いでしょうし。

結局iOS 12のサポート終了が当初の予定より遅れましたが、方法3のままで進みました。どうやって実装したのか説明しようと思いますが、その前にカスタムシンボルをもう少し説明しておきましょう。

カスタムシンボルとは

カスタムシンボルを紹介するには、まずSF Symbolsの話をしなければいけません。SF SymbolsはiOS 13以上に使える機能で、iOS開発者がアプリで使えるシンボル(色んなサイズで使えるシンプルな単色アイコン)です。普通の固定サイズの画像ではなく、文字と一緒に使えるように設計されています:サイズはフォントのポイントサイズで指定しますし、配置はフォントのベースラインに合わせることができます。

Appleの用意してくれたSF SymbolsはSF Symbolsアプリで以下のようにリストを見たり検索したりできます。

f:id:vincentisambart:20201228112934p:plain

Appleの用意してくれたシンボルだけではなく、自分の用意したカスタムなシンボルも合わせて使えます。カスタムなシンボルを用意するには、公式ガイドに従うと、まず公式のSF Symbolsアプリで追加したいシンボルに一番近いシンボルを選んで、SVGとしてエキスポートします。そのSVGをベクター画像編集ソフト(Illustratorなど)で編集して、Xcodeで使えるシンボルを用意します。

クックパッド内で使われているCookpadSymbolsはシンボルが現状300個近くあります。1つずつ手動で編集するとしたら手間が大きいです。運用変更の主な経緯がデザイナーにとってもっと運用しやすくなるためでしたので、手動でやりたくありません。自動化はプログラマーの大事な役目ですし、SVGは結局XML なので、なんとかなると思って作業を始めました。

SVGをXcodeに読み込ませてみる

SVGは既にデザイナーによって用意されていました。因みにそのSVGはウェブでもAndroidでも使われています。用意されていたSVGの1つが以下の通りでした(中身を細かく理解する必要はありません)。

<svg height="64"width="64"xmlns="http://www.w3.org/2000/svg"viewBox="0 0 64 64"><circle cx="32"cy="12"r="8"/><path d="M52.7 50.941l-7.913-4.396-3.335-8.34-.642-8.994 3.257.723 1.517 6.826a3.504 3.504 0 004.176 2.658 3.5 3.5 0 002.658-4.176l-2-9a3.5 3.5 0 00-2.658-2.658l-9-2a3.416 3.416 0 00-1.276-.037c-.16-.022-.319-.047-.484-.047h-8c-.163 0-.32.031-.479.055a3.48 3.48 0 00-3.254 1.26l-7.163 8.953-4.679.781a3.501 3.501 0 001.15 6.904l6-1a3.513 3.513 0 002.158-1.266l4.18-5.225 1.346 6.279-6.126 8.752A3.503 3.503 0 0021.5 49v9a3.5 3.5 0 107 0v-7.896l5.322-7.604h1.808l3.12 7.801a3.51 3.51 0 001.55 1.76l9 5a3.5 3.5 0 004.759-1.36 3.5 3.5 0 00-1.359-4.76z"/></svg>

ウェブブラウザーやベクター画像編集ソフトに読み込ませてみると、以下のように表示されます。

f:id:vincentisambart:20201228112940p:plain

Xcode 12がSVGを読み込めるので、深く考えずにこのSVGをXcodeでAsset Catalogにドラッグ&ドロップしてみると以下のようになります。

f:id:vincentisambart:20201228113043p:plain

求めているものとだいぶ違います。用意されていたSVGが最適化されている(不要なものが省いてある)ように見えるので、その最適化のどこかがXcodeと相性が悪いのかなと思いました。中身をよく見ると、最適化されているように見えるとはいえ、path004.176のように、数字の冒頭に無駄に見える0がある箇所があるのが少し不自然に感じました。単なるテキスト(XML)ファイルなので、ネット上のSVGの仕様をチラ見してから、試しにテキストエディターですべての不自然な0の後にスペースを入れてみて(004.1760 0 4.176など)、改めてXcodeに読み込ませてみたら以下のようになりました。

f:id:vincentisambart:20201228113052p:plain

まだ完璧ではないが、だいぶよくなりました。やはりXcodeの使っているSVG読み込みコードのSVGの仕様の解釈が不完全なようです。

XcodeのSVGの解釈を自分で補うことにするとしたらSVGの仕様を細かく理解する必要が出てくるので、自分でやる前にやってくれるツールがないでしょうか。

デザイナーに用意されていたSVGのレポジトリをよく見てみたら、SVGはSVGOというツールを使って最適化されていたようです。そのツールの設定を調べてみたら、それらしいpathに関する設定がありました。既にあった設定ファイルsvgo.ymlの最後に以下の2行を足して、SVGOを実行してみたら、なんと用意されたどのSVGも無事にXcodeに読み込まれるようになりました。

  - convertPathData:
      # Xcode doesn't handle properly paths without spaces after flags
      noSpaceAfterFlags: false

f:id:vincentisambart:20201228113102p:plain

1つだけの設定変更で済んで良かったです。

SVGファイルが以前に比べてほんの少し大きくなりますが、プラットフォームごとに設定を変えるとしたら運用が大変なので、どのプラットフォームも上記の設定で最適化されたSVGを使うことにしました。

Xcodeが読み込めるSVGになったのは大事な第一歩ですが、SVGを普通の画像としてではなく、シンボルとして使いたいので、SVGを元にシンボルを用意する必要があります。

シンボルを用意

公式ガイドに従うと、シンボルの用意の第一歩がSF Symbolsアプリから既存のSF Symbolsをまずエキスポートすることです。一番シンプルそうなcircleをエキスポートすると、以下のようなSVGファイルが書き出されます。

f:id:vincentisambart:20201228113116p:plain

シンボルごとにサイズ3つ、ウェイト8つを用意できますし、全部用意できたら一番良いのでしょうが、公式ガイドを読むとRegular Medium(Regular-M)だけが必須です。ひとまずは必須のもののみを用意することにしました。図形の縮尺を変えるだけなら、他のサイズはあとで簡単にできそうですし。

シンボルの運用を楽にしたいので、SF Symbolsアプリからエキスポートしたテンプレートに既存のSVGの中身を入れるのはガイドの説明のように手動ではなく、スクリプトでやることにしました。僕にとって書きやすいからRubyで書きましたが、XMLを扱うライブラリがあれば、どの言語でも簡単にできると思います。以下のコードはシンプルにしてコメントを多めにしたので、Rubyが分からなくてもやっているこを問題なく追えると思います。コード内のセレクターはできるだけCSSセレクターを使っています(#abcdがXML内にidの値がabcdであるノードを示します)。

最初は前準備です。ライブラリを読み込んで、必要な定数を定義して、テンプレートを読み込みます。

require"nokogiri"# XMLライブラリを使います# SF SymbolsアプリからエキスポートしたファイルへのパスTEMPLATE_PATH = "path/to/circle.svg"# 用意されたSVGへのパスSOURCE_SVG_PATH = "icon.svg"# 出力されるSVGへのパスDESTINATION_SVG_PATH = "icon-symbol.svg"# 期待されているアイコンサイズICON_WIDTH = 64ICON_HEIGHT = 64# SF Symbolsに近いサイズになるために必要な倍率(色々試した結果これで良さそうでした)ADDITIONAL_SCALING = 1.7# SVG内の#left-marginと#right-marginの幅MARGIN_LINE_WIDTH = 0.5# 左右に足している余白ADDITIONAL_HORIZONTAL_MARGIN = 4# テンプレートを読み込みます
template_svg = File.open(TEMPLATE_PATH) do |f|
  # もっときれいなSVGを生成するために、ホワイトスペースを無視するようにNokogiri::XML(f) { |config| config.noblanks }
end

テンプレートが3つのグループ(#Notes, #Guides, #Symbols)に分かれているXML(SVG)です。

<?xml version="1.0" encoding="UTF-8"?><!--Generator: Apple Native CoreSVG 149--><!DOCTYPE svg
PUBLIC"-//W3C//DTD SVG 1.1//EN""http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg version="1.1"xmlns="http://www.w3.org/2000/svg"xmlns:xlink="http://www.w3.org/1999/xlink"width="3300"height="2200"><!--glyph: "uni100000.medium", point size: 100.000000, font version: "Version 16.0d18e1", template writer version: "8"--><g id="Notes">
  (中略)
 </g><g id="Guides">
  (中略)
 </g><g id="Symbols">
  (中略)
 </g></svg>

#Symbolsグループにシンボルが以下のように入っています

<g id="Symbols"><g id="Black-L"transform="matrix(1 0 0 1 2854.05 1556)"><path d="(中略)"/></g><g id="Heavy-L"transform="matrix(1 0 0 1 2558.39 1556)"><path d="(中略)"/></g><g id="Bold-L"transform="matrix(1 0 0 1 2262.88 1556)">

必須の#Regular-M以外のシンボルは用意しないので、消しておく必要があります。

TEMPLATE_ICON_SIZES = ["S", "M", "L"]
TEMPLATE_ICON_WEIGHTS = ["Black", "Heavy", "Bold", "Semibold", "Medium", "Regular", "Light", "Thin", "Ultralight"]

# "Regular-M"だけを入れるので、それ以外の図形を消しますTEMPLATE_ICON_SIZES.each do |size|
  TEMPLATE_ICON_WEIGHTS.each do |weight|
    id = "#{weight}-#{size}"nextif id == "Regular-M"# 必須な図形だけを残します
    template_svg.at_css("##{id}").remove
  endend

テンプレートの冒頭の#Notesグループが主にベクター画像編集ソフトで見るためにあるテキストです。

<g id="Notes"><rect height="2200"id="artboard"style="fill:white;opacity:1"width="3300"x="0"y="0"/><line id=""style="fill:none;stroke:black;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="292"y2="292"/><text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;font-weight:bold;"transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text><text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:middle;"transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
  (中略)
  <text id="template-version"style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:end;"transform="matrix(1 0 0 1 3036 1933)">Template v.2.0</text>
  (中略)
 </g>

#Notesという名前だから消しても問題ないと最初は思いましたが、まるまる消してはいけません。実は公式ドキュメントをちゃんと読むと書いてありますが、#Notesの中に#template-versionという大事なテキストノードがあります。#template-versionノードを消してしまうと、シンボルSVG内の左右のマージンの位置やその中の図形の水平位置が無視されてしまいます。#artboardを消さないのも推奨されています。 余計なノードを消したいなら、#Notesの子ノードの中でidが空文字列な場合や存在しないノードだけが良いかと思います。

#Notesグループのすぐ下に大事な#Guidesグループがあります。

<g id="Guides">
  (中略)
  <line id="Baseline-S"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="696"y2="696"/><line id="Capline-S"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="625.541"y2="625.541"/>
  (中略)
  <line id="Baseline-M"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="1126"y2="1126"/><line id="Capline-M"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="1055.54"y2="1055.54"/>
  (中略)
  <line id="left-margin"style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;"x1="1391.3"x2="1391.3"y1="1030.79"y2="1150.12"/><line id="right-margin"style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;"x1="1508.39"x2="1508.39"y1="1030.79"y2="1150.12"/></g>

Regular-Mシンボルだけを用意するので、そのシンボルの#Baseline-M#Capline-Mに対する垂直位置、#left-margin#right-marginに対する水平位置、が大事になります。それぞれのグループの位置を取得しておきます。

因みにシンボルが文字の横に置かれるように設計されているため、capline(キャップライン)もbaseline(ベースライン)もフォントに関する用語です。上記のテンプレートの画像を見ると、左側に参照用にAがあるのはそのためです。

defget_guide_value(template_svg, axis, xml_id)
  guide_node = template_svg.at_css("##{xml_id}")
  raise"invalid axis"unless%i{x y}.include?(axis)
  val1 = guide_node["#{axis}1"]
  val2 = guide_node["#{axis}2"]
  if val1 == nil || val1 != val2
    raise"invalid #{xml_id} guide"end
  val1.to_f
end# #left-marginノードの"x1"の値("x2"と同じ値のはず)を取得
original_left_margin = get_guide_value(template_svg, :x, "left-margin")
# #right-marginノードの"x1"の値("x2"と同じ値のはず)を取得
original_right_margin = get_guide_value(template_svg, :x, "right-margin")
# #Baseline-Mノードの"y1"の値("y2"と同じ値のはず)を取得
baseline_y = get_guide_value(template_svg, :y, "Baseline-M")
# #Capline-Mノードの"y1"の値("y2"と同じ値のはず)を取得
capline_y = get_guide_value(template_svg, :y, "Capline-M")

SVGアイコンを読み込んで期待しているサイズなのか確認しておきます。

# アイコンのSVGを読み込みます。
icon_svg = File.open(SOURCE_SVG_PATH) do |f|
  # もっときれいなSVGを生成するために、ホワイトスペースを無視するようにNokogiri::XML(f) { |config| config.noblanks }
end# デザイナーに用意されていたSVGはサイズが64x64固定でしたので、後の計算はそれを元に書かれています。# 期待しているサイズでなければエラーで終了します。# SVGのwidth/heightは数字だけではなく、パーセントとかも使えるので、もっと幅広いSVGに対応する場合、もっと複雑になります。if icon_svg.root["width"] != ICON_WIDTH.to_s ||
  icon_svg.root["height"] != ICON_HEIGHT.to_s ||
  icon_svg.root["viewBox"] != "0 0 #{ICON_WIDTH}#{ICON_HEIGHT}"raise"expected icon size of #{icon.source_svg_path} to be (#{ICON_WIDTH}, #{ICON_HEIGHT})"end

用意されたアイコンのサイズをAppleのテンプレートのサイズに合わせる必要があります。

SF Symbolsアプリからエキスポートされるテンプレートは選ばれたシンボルによって左右のマージンの位置が変わりますが、#Baseline-M#Capline-Mが固定なので、サイズを#Baseline-M#Capline-Mの間隔に合わせます。

scale = ((baseline_y - capline_y).abs / ICON_HEIGHT) * ADDITIONAL_SCALING
horizontal_center = (original_left_margin + original_right_margin) / 2

scaled_width = ICON_WIDTH * scale
scaled_height = ICON_HEIGHT * scale

# テンプレートのマージンをそのまま使う場合、出来上がったシンボルの幅が選んだテンプレートによって変わります。# テンプレートを気にしたくないので、計算したシンボルのサイズを元に左右のマージンの位置を調整します。
horizontal_margin_to_center = scaled_width / 2 + MARGIN_LINE_WIDTH + ADDITIONAL_HORIZONTAL_MARGIN
adjusted_left_margin = horizontal_center - horizontal_margin_to_center
adjusted_right_margin = horizontal_center + horizontal_margin_to_center
left_margin_node = template_svg.at_css("#left-margin")
left_margin_node["x1"] = adjusted_left_margin.to_s
left_margin_node["x2"] = adjusted_left_margin.to_s
right_margin_node = template_svg.at_css("#right-margin")
right_margin_node["x1"] = adjusted_right_margin.to_s
right_margin_node["x2"] = adjusted_right_margin.to_s

全ての計算が終わったので、調整したテンプレートに読み込んだアイコンを正しい位置とサイズで入れてファイルを出力します。

# 元のテンプレートをコピーする。# 今回シンボル1つしか生成しないが、一気にいくつものシンボルを生成する場合コピーを編集した方が安全です。
symbol_svg = template_svg.dup

# ついに肝心の#Regular-Mノードに手をつける時が来ました。
regular_m_node = symbol_svg.at_css("#Regular-M")

# 図形がガイドの中央になるよう移動させます。
translation_x = horizontal_center - scaled_width / 2
translation_y = (baseline_y + capline_y) / 2 - scaled_height / 2# 上記に計算された移動や倍率を元に変換行列を用意します。
transform_matrix = [
  scale, 0,
  0, scale,
  translation_x, translation_y,
].map {|x| "%f" % x } # 文字列に変換
regular_m_node["transform"] = "matrix(#{transform_matrix.join("")})"# #Regular-Mノードの中身を用意されていたアイコンに置き換えます。
regular_m_node.children = icon_svg.root.children.dup

# 最後に生成したシンボルを書き出します。File.open(DESTINATION_SVG_PATH, "w") do |f|
  symbol_svg.write_to(f)
end

実装中に起きた問題

もちろん上記のコードが出来上がるまでは、スクリプトを実行して、ベクター画像編集ソフトやXcodeで確認して、スクリプトの修正する、の繰り返しでした。実装が進んでいたら、Xcodeでの確認はAsset Catalog内だけではなく、普通のXcodeプロジェクトに取り込んで使ってみるのも含んでいました。

問題の1つは、色んなSVGからシンボルを生成したら、一部の生成されたシンボルファイルに元の図形の横に別の図形がありました。よく見たら、用意されていたSVGの一部に(0, 0, 64, 64)枠の外に図形が入っていました。viewport0 0 64 64だったのでその外が見えていなくて誰も気づいていませんでした。デザイナーにその枠外図形を消してもらいました。

もう1つは実装の説明でも書きましたが、#Notesノードが要らないだろうと思って消してしまったが間違いでした。それで図形を左右マージンの間にどこに置いても(中央寄りでも左寄りでも右寄りでも)、左右マージンをもっと幅広くしても、生成されたシンボルが変わりませんでした。#Notesに入っている#template-versionが残るように修正することで期待通りに動くようになりました。

幅固定のよしあし

上記のスクリプトで生成されたシンボルはAsset Catalogを入れて問題なく使えます。ただし、幅をすべてのシンボル共通にしましたが、それに良し悪しがあります。枠の幅が共通でも、その中の図形自体の幅がそれぞれなので、左右の余白がバラバラです。iOSのカスタムシンボルはシンボルごとに幅を変えられますし、実際Appleの用意したSF Symbolsの幅が様々です。そうした主な理由が2つあります。

  • いくつかのシンボルを同じ画面内で配置する場合、幅が共通だった方が配置しやすいと思います。
  • 図形の形を解析して本当の幅を計算するのとなると複雑になりますし、もっと細かく確認する必要があるからです。また、その道を歩み始めると、本当のサイズと目に見えるサイズ(光学的サイズ)がちょっと違ったりしますし、シンボルによって微調整したくなったりします。

どうするのかユースケースによると思いますが、シンプルでいくことにしました。

因みに幅を共通にしましたが、なぜかコードでシンボルから生成された画像はシンボルによって幅に0.5~1.0 ptの差があります。iOS 13よりiOS 14の方がましのようだけど、iOS 14でも起きています。まぁpixel perfectを求めるなら、ベクター画像ではなく、ピクセル画像を用意することですね。

もう少し便利に

上記のスクリプトは分かりやすさのためSVG 1つだけを生成するものです。社内で用意したスクリプトはそれより少し強力です。

元はファイル1つではなく、特定なディレクトリーのすべてのSVGファイルを処理していきますし、生成しているのはSVGだけではなく、Asset Catalog(xcassetsディレクトリー)を丸々生成していますし、シンボルのリストのSwift enumのコードも生成しています。

Asset Catalogは形式がとても簡単です。Asset Catalogはフォルダーの「Provides Namespace」にチェックを入れるとその中身がネームスペースに入るので便利です。

以下のような enumのコードを生成しています。

publicenumCookpadSymbol:String, CaseIterable {
    publicenumPackage {
        // Asset Catalogにカスタムシンボルを入れたネームスペースpublicstaticletnamespace="cookpad"publicstaticletversion="2.0.0"
    }

    case access
    case clip
    case clipAdd ="clip_add"case clipAdded ="clip_added"case clipRemove ="clip_remove"case lock

    // Asset Catalog内の名前publicvarimageName:String { "\(Package.namespace)/\(rawValue)" }
}

カスタムシンボルの使い方

シンボルを生成したのは良いが、Asset Catalogに入れたらアプリ内でどうやって使えるのでしょうか。

UIImageView

カスタムシンボルを表示するには基本的にUIImageViewを使います。

letsymbolIconView= UIImageView()
// CookpadSymbol.imageNameが上記にenumに定義されたAsset Catalog内の名前です。
symbolIconView.image = UIImage(named:CookpadSymbol.lock.imageName, in: .main)
symbolIconView.tintColor = .red
symbolIconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize:10)

preferredSymbolConfigurationでサイズが決まります。ただし、UIImage.SymbolConfiguration(pointSize: 10)を使うとDynamic Type設定の変更が反映されません。Dynamic Type対応が必要な場合、UIImage.SymbolConfiguration(textStyle:)を使うか、Dynamic Typeの設定によってサイズを変えるフォントをUIImage.SymbolConfiguration(font:)に渡すかです。

letsymbolConfiguration= UIImage.SymbolConfiguration(font:UIFontMetrics.default.scaledFont(for: .systemFont(ofSize:10)))

UILabelと違ってadjustsFontForContentSizeCategoryのように別途に設定する必要あるプロパティがありません。

NSAttributedString

UIImageViewの他に、NSAttributedStringに入れて、UILabelUITextViewでも表示できます。

letattributedText= NSMutableAttributedString()
letimageAttachment= NSTextAttachment()
imageAttachment.image = UIImage(named:CookpadSymbol.lock.imageName, in: .main)
attributedText.append(NSAttributedString(attachment:imageAttachment))
attributedText.append(NSAttributedString(string:" 非公開"))
label.attributedText = attributedText

UILabel.attributedTextの懸念点はUILabel.textと違って、adjustsFontForContentSizeCategorytrueにしても、Dynamic Typeの設定変更がすぐ反映されないところです。

UIImage

カスタムシンボルをUIImageとして扱いたい場合、サイズをUIImage.SymbolConfigurationで明記して、UIImage(named:in:with:)に渡すか、UIImage.applyingSymbolConfiguration()(またはUIImage.withConfiguration())に渡すかです。

letconfiguration= UIImage.SymbolConfiguration(pointSize:12)
letsymbolImage= UIImage(named:CookpadSymbol.lock.imageName, in: .main, with:configuration)

色の指定はUIImage.withTintColor()を使います。

letredSymbolImage= symbolImage?.withTintColor(.red)

tintColorを指定しても、シンボルから作成したUIImageUIImageViewに入れるとき、UIImageViewtintColorが優先されるので、どうしても画像自体の色を優先させたい場合は以下のようにできます。

letreallyRedSymbolImage= symbolImage?.withTintColor(.red, renderingMode: .alwaysOriginal)

SwiftUI

SwiftUIでも簡単に使えます。

Image(CookpadSymbol.arrowRight.imageName, bundle: .main)
    .font(.caption)
    .foregroundColor(.green)

ヘルパー

生成されたenumにいくつかのヘルパーを用意するとさらに使いやすくなります。ここでBundleは固定で.mainを渡していますが、自分のユースケースに合わせてください。

// UIKitextensionCookpadSymbol {
    publicfuncmakeImage(with configuration:UIImage.Configuration? =nil) ->UIImage? {
        UIImage(named:imageName, in: .main, with:configuration)
    }

    publicfuncmakeAttributedString(
        with configuration:UIImage.Configuration? =nil,
        tintColor:UIColor? =nil
    ) ->NSAttributedString {
        varimage= makeImage(with:configuration)
        iflettintColor= tintColor {
            image = image?.withTintColor(tintColor)
        }
        letimageAttachment= NSTextAttachment()
        imageAttachment.image = image
        return NSAttributedString(attachment:imageAttachment)
    }
}

// SwiftUIextensionImage {
    publicinit(_ symbol:CookpadSymbol) {
        self.init(symbol.imageName, bundle: .main)
    }
}

SF Symbols

余談ですが、上記のコードがカスタムシンボルのためですが、UIImage(named:in:)UIImage(systemName:)に変えると、SF Symbolsで使えます。カスタムシンボルがカスタマイズされたSF Symbolsなので、使い方が近いのは自然かと思います。

Interface Builder

Interface Builder(Xcode内インターフェースエディター)内でImage ViewのプロパティでAsset Catalogのように簡単にカスタムシンボルを選ぶことができますし、コードのようにサイズを簡単に選べます(ただしUIFontMetricsを通ったフォントは渡せません)。

f:id:vincentisambart:20201228113122p:plain

やってみてどうだった

カスタムシンボルの作り方の公式ガイドに自動化に関する話はありませんでしたが、SVGはベクター画像編集ソフトでもテキストエディターでも確認できるファイル形式ですし、デザイナーが用意してくれていたSVGがきれいでシンプルでしたので、カスタムシンボルの生成は割りとスムーズにできたと思います。今後もっと幅広く使えるカスタムシンボルを扱うツールが増えたらさらに楽になるかと思います。

カスタムシンボルを使い始めてから時間がまだあまり経っていないので、今後気づく懸念点は出てくるかもれませんが、いまのところ簡単に色んな場面で使えて便利です。

SF SymbolsもカスタムシンボルもiOS 13以上を必要としているのは一番の懸念点だと思いますが、時間が解決してくれます。

Generating custom SF Symbols from existing SVG files

$
0
0

(日本語版はこちらへ)

Hello and Happy New Year! I'm Vincent (@vincentisambart) from the Mobile Infrastructure team here at Cookpad Japan.

Recently, Apple has been putting a lot of energy into SF Symbols, symbols to use on your app's screens. SF Symbols allows you to not only use symbols created by Apple, but also custom ones you made yourself. To create custom symbols, following the official workflow seemed quite time-consuming, so I tried to automatically generate custom symbols from existing SVG files instead.

How it started

For symbols, single color icons, the iOS Cookpad Japan app has been using CookpadSymbols, a font made only of symbols, for quite a while. However, a few months ago, our designers asked if we could switch from using a font to using SVG files.

The icons used in the font were already made from SVGs, but designers wanted to simplify the process, and not have to load the vector files into some online tool to generate a new version of the font every time a change was made. And these days SVG files can be used directly in a lot of places so that should be doable.

You can see how a few CookpadSymbols symbols look like below.

f:id:vincentisambart:20201228112920p:plain

On iOS there are 3 different ways to use SVGs as symbols.

  1. Use them as pixel images of a specific size (as if they had been converted to PNG)
  2. Use them as vector data ("Preserve Vector Data" setting in asset catalogs)
  3. Starting from Xcode 12 you can use SVG files directly, but to use them as vector data on iOS 12 and below, it seems that you have to first convert them to PDF.
  4. Use them as custom symbols (custom SF Symbols)
  5. Requires iOS 13 and above.

The iOS Cookpad Japan app had been using symbols from a font, changing their displayed size depending on the screen, so using fixed size images would be pretty inconvenient. The source is a vector image so you can easily generate a lot of differently sized images, but still.

Apple has recently been pushing SF Symbols, and at the time our designers asked for switching from a symbol font, we were already talking about stopping support for iOS 12 very shortly after. So I decided to go for choice 3. If we ended up delaying stopping support for iOS 12 for a long period of time, or if implementation of that choice ended up being too complicated, I could always fall back to choice 2.

In the end, stopping support of iOS 12 was delayed a bit, but I still went to choice 3. Before explaining how the implementation went, we should probably have a better look at custom symbols.

Custom Symbols

To introduce custom symbols, we first have to talk about SF Symbols. SF Symbols is a feature available starting from iOS 13, providing symbols (one color icons that can be used at any size) that developers can use in their apps. In contrast with normal fixed size images, they are made to be used in conjunction with text: their size is specified with a font size, and their baseline can be properly aligned with text.

You can see a list and search through SF Symbols inside the SF Symbols app provided by Apple as you can see in the screenshot below.

f:id:vincentisambart:20201228112934p:plain

You are not limited to the symbols provided by Apple, you can also use your own custom symbols. To make you own custom symbols, if you follow the official guide, you first have to choose in the SF Symbols app an existing symbol close to the one you want to make, and export it as SVG. You then edit it in a vector graphics editor (like Illustrator) to get a symbol usable in Xcode.

The existing symbol font CookpadSymbols had close to 300 symbols. Editing them one by one would take a lot of effort. We are trying to make life easier for designers, so giving them more work would not make much sense. Automation is a bit part of the job of developers, and after all SVG is XML, making it pretty malleable, so I started working on it.

Trying to load an existing SVG into Xcode

The SVG files were already prepared by designers (by the way those SVGs are also used on the Web and Android). You can see one of those SVGs below (no need to try to understand the content in detail).

<svg height="64"width="64"xmlns="http://www.w3.org/2000/svg"viewBox="0 0 64 64"><circle cx="32"cy="12"r="8"/><path d="M52.7 50.941l-7.913-4.396-3.335-8.34-.642-8.994 3.257.723 1.517 6.826a3.504 3.504 0 004.176 2.658 3.5 3.5 0 002.658-4.176l-2-9a3.5 3.5 0 00-2.658-2.658l-9-2a3.416 3.416 0 00-1.276-.037c-.16-.022-.319-.047-.484-.047h-8c-.163 0-.32.031-.479.055a3.48 3.48 0 00-3.254 1.26l-7.163 8.953-4.679.781a3.501 3.501 0 001.15 6.904l6-1a3.513 3.513 0 002.158-1.266l4.18-5.225 1.346 6.279-6.126 8.752A3.503 3.503 0 0021.5 49v9a3.5 3.5 0 107 0v-7.896l5.322-7.604h1.808l3.12 7.801a3.51 3.51 0 001.55 1.76l9 5a3.5 3.5 0 004.759-1.36 3.5 3.5 0 00-1.359-4.76z"/></svg>

If you try loading it in a web browser or vector graphics editor, you get this.

f:id:vincentisambart:20201228112940p:plain

Xcode 12 can read SVGs, so without thinking too much, if you try to drag & drop the same file in an asset catalog, you get the following.

f:id:vincentisambart:20201228113043p:plain

Pretty different to the expected result... Looking at the content of the SVG, the file content seemed optimized (not containing any non-required information), so I thought the file not appearing correctly might be due to Xcode not handling that type of optimized SVG properly. Looking more closely at the content, even though it seems optimized, in path, having some numbers starting with 0s looked unnatural to me (for example 004.176). It is just a text (XML) file, so after having a quick look at the SVG specs, I loaded the SVG in a text editor, and tried adding spaces after each of those unnatural 0s (for example 004.1760 0 4.176). Loading the modified file into Xcode gave the following.

f:id:vincentisambart:20201228113052p:plain

Not perfect yet, but still better than we had before. So it seems that Xcode's SVG parsing is indeed a bit limited.

Compensating for Xcode's poor understanding of the SVG format would require spending time to learn and understand the SVG specs, so before even thinking of trying that, first isn't there a tool that would do it for us?

Looking at the repository for the SVGs that the designers had created, it seemed they were using a tool called SVGO for optimizing the SVGs. Looking at that tool's settings, there was a path-related setting that looked related. After adding the 2 lines below to the already existing svgo.yml setting file, and then running SVGO, all updated SVGs could now be properly loaded into Xcode.

  - convertPathData:
      # Xcode doesn't handle properly paths without spaces after flags
      noSpaceAfterFlags: false

f:id:vincentisambart:20201228113102p:plain

SVG being loaded properly with only setting change was a relief.

With the new settings, the SVG files became a tiny bit bigger due to the added spaces, but using different settings depending on the platform would be cumbersome, so we chose to use these settings for all platforms.

Being able to read these SVGs into Xcode was an important first step, but we want to handle them not as normal images but as symbols, so we now have to prepare symbols from these SVGs.

Providing symbols

Following the official guide, providing symbols first requires to export an existing symbol from the SF Symbols app. If you export what seems to be one of the simplest symbols circle, you get an SVG that looks like the following.

f:id:vincentisambart:20201228113116p:plain

For one symbol you can provide 3 sizes and 8 weights, and providing all of them would probably be the best, but reading the official guide, only Regular Medium (Regular-M) is required. I chose to only provide the required shape at first. If providing other sizes just requires some simple scaling, generating other sizes afterwards should be pretty easy.

The goal was to make managing symbols easy, so I decided to insert the SVGs' content into a template exported from the SF Symbols app, not by hand as the guide said, but automatically with a script. I wrote that script in Ruby as that was the easiest for me, but you should be able to do it pretty easily in any language with a good XML handling library. I tried making the code below simple and added many comments, so you should be able to follow along without knowing much Ruby. In the code I'm using CSS selectors as much as possible (#abcd points to nodes in the XML for which id is abcd).

We first start with a simple setup. Load the library we are going to use, define constants, and load the template.

require"nokogiri"# Load the XML library we are going to use.# Path to file exported from the SF Symbols appTEMPLATE_PATH = "path/to/circle.svg"# Path to one of the SVGs provided by the designersSOURCE_SVG_PATH = "icon.svg"# Path to the SVG we are generatingDESTINATION_SVG_PATH = "icon-symbol.svg"# Expected icon sizeICON_WIDTH = 64ICON_HEIGHT = 64# Additional scaling to have a size closer to Apple's provided SF Symbols# (I just tried different values and that looked pretty close)ADDITIONAL_SCALING = 1.7# Width of #left-margin and #right-margin inside the SVGMARGIN_LINE_WIDTH = 0.5# Additional white space added on each sideADDITIONAL_HORIZONTAL_MARGIN = 4# Load the template.
template_svg = File.open(TEMPLATE_PATH) do |f|
  # To generate a better looking SVG, ignore whitespaces.Nokogiri::XML(f) { |config| config.noblanks }
end

The template is an XML (SVG) split into 3 groups (#Notes, #Guides, #Symbols).

<?xml version="1.0" encoding="UTF-8"?><!--Generator: Apple Native CoreSVG 149--><!DOCTYPE svg
PUBLIC"-//W3C//DTD SVG 1.1//EN""http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg version="1.1"xmlns="http://www.w3.org/2000/svg"xmlns:xlink="http://www.w3.org/1999/xlink"width="3300"height="2200"><!--glyph: "uni100000.medium", point size: 100.000000, font version: "Version 16.0d18e1", template writer version: "8"--><g id="Notes">
  (...)
 </g><g id="Guides">
  (...)
 </g><g id="Symbols">
  (...)
 </g></svg>

The symbols are included into the #Symbols as you can see below.

<g id="Symbols"><g id="Black-L"transform="matrix(1 0 0 1 2854.05 1556)"><path d="(...)"/></g><g id="Heavy-L"transform="matrix(1 0 0 1 2558.39 1556)"><path d="(...)"/></g><g id="Bold-L"transform="matrix(1 0 0 1 2262.88 1556)">

We will not provide symbols other than #Regular-M so we have to remove the other ones.

TEMPLATE_ICON_SIZES = ["S", "M", "L"]
TEMPLATE_ICON_WEIGHTS = ["Black", "Heavy", "Bold", "Semibold", "Medium", "Regular", "Light", "Thin", "Ultralight"]

# We are only providing "Regular-M", so remove the other shapes.TEMPLATE_ICON_SIZES.each do |size|
  TEMPLATE_ICON_WEIGHTS.each do |weight|
    id = "#{weight}-#{size}"nextif id == "Regular-M"# Only leave the mandatory shape.
    template_svg.at_css("##{id}").remove
  endend

The #Notes group is mostly text to see in a vector graphics editor.

<g id="Notes"><rect height="2200"id="artboard"style="fill:white;opacity:1"width="3300"x="0"y="0"/><line id=""style="fill:none;stroke:black;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="292"y2="292"/><text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;font-weight:bold;"transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text><text style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:middle;"transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
  (...)
  <text id="template-version"style="stroke:none;fill:black;font-family:-apple-system,&quot;SF Pro Display&quot;,&quot;SF Pro Text&quot;,Helvetica,sans-serif;text-anchor:end;"transform="matrix(1 0 0 1 3036 1933)">Template v.2.0</text>
  (...)
 </g>

From its name, it looks like #Notes could be removed without any problem, but in fact if you read the official documentation properly, it tells you that the #template-version text node inside #Notes is important. If you remove it, the position of left and right margins and the horizontal position of the shape inside those margins will be ignored. It is also recommended to not remove #artboard. If you really want to remove all unneeded nodes, removing child nodes of #Notes that have no id or an empty one might be OK.

Below the #Notes group, there is a very important #Guides group.

<g id="Guides">
  (...)
  <line id="Baseline-S"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="696"y2="696"/><line id="Capline-S"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="625.541"y2="625.541"/>
  (...)
  <line id="Baseline-M"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="1126"y2="1126"/><line id="Capline-M"style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;"x1="263"x2="3036"y1="1055.54"y2="1055.54"/>
  (...)
  <line id="left-margin"style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;"x1="1391.3"x2="1391.3"y1="1030.79"y2="1150.12"/><line id="right-margin"style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;"x1="1508.39"x2="1508.39"y1="1030.79"y2="1150.12"/></g>

We are only providing a Regular-M symbol, so its vertical position between #Baseline-M and #Capline-M, and its horizontal position between #left-margin and #right-margin, are important. So we want to get the position of each of those guides.

By the way, as symbols have been designed to be used in conjunction with text, capline and baseline are typography terms. That is why if you look at the image of the template I included above, on the left side you have an A to be used as a reference.

defget_guide_value(template_svg, axis, xml_id)
  guide_node = template_svg.at_css("##{xml_id}")
  raise"invalid axis"unless%i{x y}.include?(axis)
  val1 = guide_node["#{axis}1"]
  val2 = guide_node["#{axis}2"]
  if val1 == nil || val1 != val2
    raise"invalid #{xml_id} guide"end
  val1.to_f # Convert the value from string to float.end# Get the x1 (should be the same as x2) of the #left-margin node.
original_left_margin = get_guide_value(template_svg, :x, "left-margin")
# Get the x1 (should be the same as x2) of the #right-margin node.
original_right_margin = get_guide_value(template_svg, :x, "right-margin")
# Get the y1 (should be the same as y2) of the #Baseline-M node.
baseline_y = get_guide_value(template_svg, :y, "Baseline-M")
# Get the y1 (should be the same as y2) of the #Capline-M node.
capline_y = get_guide_value(template_svg, :y, "Capline-M")

We then load the SVG icon and check if it has the expected size.

# Load the SVG icon.
icon_svg = File.open(SOURCE_SVG_PATH) do |f|
    # To generate a better looking SVG, ignore whitespaces.Nokogiri::XML(f) { |config| config.noblanks }
end# The SVGs provided by designers had a fixed size of 64x64, so all the calculations below are based on this.# If we get an unexpected size, the program ends in error.# The SVG specs allows to specify width and height in not only numbers, but also percents, so handling a wider range of SVG files would be more complicated.if icon_svg.root["width"] != ICON_WIDTH.to_s ||
  icon_svg.root["height"] != ICON_HEIGHT.to_s ||
  icon_svg.root["viewBox"] != "0 0 #{ICON_WIDTH}#{ICON_HEIGHT}"raise"expected icon size of #{icon.source_svg_path} to be (#{ICON_WIDTH}, #{ICON_HEIGHT})"end

We then have to scale the provided icon to match the template.

The position of the left and right margins depend on the symbol chosen in the SF Symbols app, but #Baseline-M and #Capline-M are always at the same position, so we scale based on the spacing between those 2 guides.

scale = ((baseline_y - capline_y).abs / ICON_HEIGHT) * ADDITIONAL_SCALING
horizontal_center = (original_left_margin + original_right_margin) / 2

scaled_width = ICON_WIDTH * scale
scaled_height = ICON_HEIGHT * scale

# If you use the template's margins as-is, the generated symbol's width will depend on the template chosen.# To not have to care about the template, we move the margin based on the computed symbol size.
horizontal_margin_to_center = scaled_width / 2 + MARGIN_LINE_WIDTH + ADDITIONAL_HORIZONTAL_MARGIN
adjusted_left_margin = horizontal_center - horizontal_margin_to_center
adjusted_right_margin = horizontal_center + horizontal_margin_to_center
left_margin_node = template_svg.at_css("#left-margin")
left_margin_node["x1"] = adjusted_left_margin.to_s
left_margin_node["x2"] = adjusted_left_margin.to_s
right_margin_node = template_svg.at_css("#right-margin")
right_margin_node["x1"] = adjusted_right_margin.to_s
right_margin_node["x2"] = adjusted_right_margin.to_s

We finished all our calculations, so we then insert the loaded icon at the correct position and size in the adjusted template, and generate a complete symbol file.

# Make a copy of the modified template.# In this script we generate only one symbol, but if we end up generating multiple symbols at one it's safer to work on a copy.
symbol_svg = template_svg.dup

# It's finally time to handle that important #Regular-M node.
regular_m_node = symbol_svg.at_css("#Regular-M")

# Move the shape so its center is at the center of the guides.
translation_x = horizontal_center - scaled_width / 2
translation_y = (baseline_y + capline_y) / 2 - scaled_height / 2# Prepare a transformation matrix from the values calculated above.
transform_matrix = [
  scale, 0,
  0, scale,
  translation_x, translation_y,
].map {|x| "%f" % x } # Convert numbers to strings.
regular_m_node["transform"] = "matrix(#{transform_matrix.join("")})"# Replace the content of the #Regular-M node with the icon.
regular_m_node.children = icon_svg.root.children.dup

# Finish by writing the generated symbol to disk.File.open(DESTINATION_SVG_PATH, "w") do |f|
  symbol_svg.write_to(f)
end

Problems that happened during implementation

Ending up with the code above required of course a lot of trial and error. Execute the script, check in a vector graphics editor and Xcode, update the script, and repeat. In the later stages, checking in Xcode was not only checking how the symbol appeared inside an asset catalog, but also trying to use the symbol in an Xcode project.

One problem that happened when I tried generating symbols from different provided SVGs, in some generated symbol files, on the side of the main shape there was some other shape. Looking a bit more at it, in some of the provided SVGs, there was a shape outside of the (0, 0, 64, 64) frame. In the source SVGs, viewport being 0 0 64 64 hid everything outside, so nobody realized that some other shape was left outside the frame. After I pointed it out the designers kindly removed those.

Another problem that I mentioned above in the implementation explanation, I first mistakenly thought #Notes could be freely removed. But if you remove that node, where you put the shape horizontally between the left and right margin, and the width between left and right margins, seem to have no effect on the generated symbol. After fixing the code to keep #Notes's children nodes with an "id" attribute (especially the #template-version node), the behavior matched my expectations.

Good and bad of fixed width

The symbols generated with the script above can be used without problem once added in an asset catalog. However, all the generated symbols have the same width, and that has its goods and bads. Even if our shapes here all fit in the same (0, 0, 64, 64) frame, the shapes themselves have different widths: the whitespace on the left and right inside that frame change depending on the symbol. iOS's custom symbols can use a different width for each symbol, and in Apple's SF Symbol multiple widths are used. The main reasons I went for a fixed width are the following.

  • When placing multiple symbols inside the same screen, having them all have the same width simplifies the layout.
  • Analyzing the shapes and calculating their real width is more complicated, and requires more hand-checking of the generated symbols. Also, if you start on that path, there's the problem that the real size of a shape and the size your eyes see (optical size) tend to be a bit different, and you start to want to be able to adjust sizes and margins per symbol.

What you choose depends on your use case, but I decided to go simple.

By the way, the width is the same for all symbols, but for some reason images generated from them have a width varying by 0.5~1.0 pts depending on the symbol. iOS 14 seems better in that regard, but it does happen even on iOS 14. I guess if you want something pixel perfect, you should probably use pixel images rather than vector ones...

A bit more convenience

For simplicity, the script above only processes one SVG. The internal script I wrote is a bit more powerful.

Its source is not only one file, but all SVG files in a specific directory, and generates a full asset catalog (xcassets directory), and also a Swift enum with the list of all the symbols.

In fact the format of an asset catalog is very simple. Also, in asset catalogs, a folder with the "Provides Namespace" checkbox checked providing a namespace is pretty convenient.

The generated enum looks like the following.

publicenumCookpadSymbol:String, CaseIterable {
    publicenumPackage {
        // Namespace where the custom symbols are in the asset catalogpublicstaticletnamespace="cookpad"publicstaticletversion="2.0.0"
    }

    case access
    case clip
    case clipAdd ="clip_add"case clipAdded ="clip_added"case clipRemove ="clip_remove"case lock

    // Name inside the asset catalogpublicvarimageName:String { "\(Package.namespace)/\(rawValue)" }
}

How to use custom symbols

It's nice to have generated those symbols, but once you added them to an asset catalog, how can you use them?

UIImageView

To display a custom symbol, you generally use a UIImageView.

letsymbolIconView= UIImageView()
// CookpadSymbol.imageName is the name inside the asset catalog as declared in the enum above.
symbolIconView.image = UIImage(named:CookpadSymbol.lock.imageName, in: .main)
symbolIconView.tintColor = .red
symbolIconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize:10)

The size is specified with preferredSymbolConfiguration. However, if you use UIImage.SymbolConfiguration(pointSize: 10), changes of Dynamic Type settings won't have an effect on the symbol size. To support Dynamic Type, you either use UIImage.SymbolConfiguration(textStyle:), or pass the font the font with the size you want to UIImage.SymbolConfiguration(font:).

letsymbolConfiguration= UIImage.SymbolConfiguration(font:UIFontMetrics.default.scaledFont(for: .systemFont(ofSize:10)))

Contrarily to UILabel, there is no adjustsFontForContentSizeCategory property to set to enable (or disable) automatic text size adjustment.

NSAttributedString

As an alternative to using UIImageView, you can put your symbol in an NSAttributedString and display it in a UILabel or UITextView.

letattributedText= NSMutableAttributedString()
letimageAttachment= NSTextAttachment()
imageAttachment.image = UIImage(named:CookpadSymbol.lock.imageName, in: .main)
attributedText.append(NSAttributedString(attachment:imageAttachment))
attributedText.append(NSAttributedString(string:" 非公開"))
label.attributedText = attributedText

You have to be careful that UILabel.attributedText works differently from UILabel.text, in that even if you set adjustsFontForContentSizeCategory to true, Dynamic Type settings changes will not be reflected on the font size when they happen.

UIImage

When you want to handle a custom symbol as a UIImage, you explicitly give a size to UIImage.SymbolConfiguration, and pass it to either UIImage(named:in:with:), or to UIImage.applyingSymbolConfiguration() (or UIImage.withConfiguration()).

letconfiguration= UIImage.SymbolConfiguration(pointSize:12)
letsymbolImage= UIImage(named:CookpadSymbol.lock.imageName, in: .main, with:configuration)

You can specify the color with UIImage.withTintColor().

letredSymbolImage= symbolImage?.withTintColor(.red)

Even if you specify a tintColor, if you put a UIImage generated from a custom symbol into a UIImageView, the UIImageView's tintColor will take precedence, so if you really want the image's color to take precedence you can do as follows.

letreallyRedSymbolImage= symbolImage?.withTintColor(.red, renderingMode: .alwaysOriginal)

SwiftUI

You can also easily use custom symbols with SwiftUI.

Image(CookpadSymbol.arrowRight.imageName, bundle: .main)
    .font(.caption)
    .foregroundColor(.green)

Helpers

Adding a few helper methods to the enum generated will make using custom symbols even easier. Here I'm always specifying .main for the Bundle, but you should set it accordingly to where your asset catalog is.

// UIKitextensionCookpadSymbol {
    publicfuncmakeImage(with configuration:UIImage.Configuration? =nil) ->UIImage? {
        UIImage(named:imageName, in: .main, with:configuration)
    }

    publicfuncmakeAttributedString(
        with configuration:UIImage.Configuration? =nil,
        tintColor:UIColor? =nil
    ) ->NSAttributedString {
        varimage= makeImage(with:configuration)
        iflettintColor= tintColor {
            image = image?.withTintColor(tintColor)
        }
        letimageAttachment= NSTextAttachment()
        imageAttachment.image = image
        return NSAttributedString(attachment:imageAttachment)
    }
}

// SwiftUIextensionImage {
    publicinit(_ symbol:CookpadSymbol) {
        self.init(symbol.imageName, bundle: .main)
    }
}

SF Symbols

As a side note, the code above is for custom symbols, but if you change UIImage(named:in:) to UIImage(systemName:), you can use it with SF Symbols. Custom symbols are customized SF Symbols so it makes sense that their use is similar.

Interface Builder

Inside Interface Builder (the interface editor inside Xcode), in Image View properties, you can easily choose your custom symbol like any other asset catalog image. You can also easily specify the size (however you cannot pass a font that went through UIFontMetrics).

f:id:vincentisambart:20201228113122p:plain

Final words

The official guide to create custom symbols does not mention automating the process, but SVG can be easily checked in vector graphics editors and text editors, and the SVG provided by designers were simple and clean, so the automatic generation of custom symbols went pretty smoothly. In the future, having more general tools to handle custom symbols would make things even easier.

It has not been long since I started using custom symbols, so they might have some disadvantages I have not realized yet, but currently I find them pretty convenient, easy to use in many different places.

The main limitation of SF Symbols and custom symbols is them being only available on iOS 13 and above, but with time that should become less of a problem.


SwiftUI を活用した「レシピ」×「買い物」の新機能開発

$
0
0

レシピ×買い物の新機能開発とSwiftUI VIPER アーキテクチャへの部分的導入とサービス開発の効率変化

こんにちは。クックパッド事業本部 買物サービス開発部の藤坂(@yujif_)です。

2020年10月にクックパッド iOS アプリで「買い物機能」をリリースしました。今回はこの新機能の開発にあたって考えたことや取り組みについてご紹介します。

f:id:y_f:20210112104942j:plain
買い物機能の画面例

買い物機能とは

生鮮食品EC「クックパッドマート」の仕組みと連携し、レシピサービス「クックパッド」のアプリから食材を注文できます*1。これはただクックパッドマートの機能を使えるだけ、というわけではありません。「レシピ」と「買い物」が融合するからこその良い体験づくりを目指しています。

詳しい内容はプレスリリースクックパッドでお買い物 - 地域限定機能をデザインする上で考えたこと-にもまとまっていますので、ぜひあわせてご覧ください。

info.cookpad.com

note.com

レシピから買い物へ

f:id:y_f:20210112110009p:plain
レシピから直接材料を買うこともできる

レシピサービスならではの良さとして、例えば「材料欄からスムーズに買える」という便利さがあります。作りたいレシピが見つかったとき、必要な食材をすぐに買い揃えられます*2

買い物からレシピへ

逆に、買い物からレシピへの流れもあります。

f:id:y_f:20210110014859p:plain
気になる食材の楽しみ方がすぐ見つかる

クックパッドマートの仕組みによって、精肉店や鮮魚店などの専門店や地域の農家など、さまざまなお店・生産者の魅力的な食材をアプリから一覧できます。

食材を眺めていて「へぇ〜こんなの買えるんだ!」「どう食べるのが良いのだろう?」と気になったら、すぐに自分好みの食べ方・楽しみ方を、クックパッドに集まる沢山のアイデアの中から見つけられます。

「明日はこれ作ろう!」「週末はこれが食べたいな〜」とワクワクしながら注文し、次の料理が楽しみになる、そんな素敵な時間を作れるかもしれません。

実は SwiftUI で作られている

買い物機能の画面は、そのほとんど全てが SwiftUI で実装されています。

SwiftUI は iOS / macOS アプリを構築できるフレームワーク*3です。2019年の WWDC で Apple から発表されました。従来の UIKit と比べて、より簡潔なコードで UI を組める点が特徴的です。

  • 利点
    • UI を作りやすく、生産性が向上する
    • 将来的に標準化しうる SwiftUI にキャッチアップできる
    • UIKit と組み合わせて使える
    • Dynamic Type、Dark Mode など iOS の最近の機能が考慮されている
  • 欠点
    • まだ挙動に不具合が残っている部分もある
    • iOS 12 以前では使えない
    • UIKit に比べると機能が足りていない
    • 知見を持つエンジニアがまだ限られている

SwiftUI はまだ比較的新しい技術であり、上記のようなメリットとデメリットが考えられます。ゼロから作る新規アプリではなく、長い歴史もあって大規模なクックパッドアプリ*4で、なぜ SwiftUI を本番投入することにしたのでしょうか?

技術選定の背景

1. 本番で早く検証し、サービス開発の効率を上げたい

「レシピ」と「買い物」を融合して、毎日の料理を楽しみにすること、それがこの買い物機能をつくる目的です。

最終的に目指す先は決まっていても、どういうコンセプトが最も良いのか、どのような機能・UI ならコンセプトを実現できるのか、具体的な形はまだ誰にも分かっていません。何度も作り変えながら模索していくことになります。

実生活の中で使って発見を増やす

新しいアイデアの検証では、最小限のプロトタイプを作り、ユーザーインタビューをして判断することが一般的です。時間を費やしすぎずに重要な知見を得られる点がメリットですが、それだけでは不十分な面もあると経験上感じています。

実際にアプリを使っていると、開発中やインタビュー中など当初は気づいていなかった価値や問題を実感して気持ちが変わることがあります。

f:id:y_f:20210112110142p:plain
実生活で試してこそ課題や価値が分かる

自宅のリビングやキッチン、通勤中、送り迎えや買い物など、現場にいる当事者だからこそ、課題に対する解像度も高く「どうなっていたら嬉しいのか?」という解決策が自然と浮かぶこともあり、結果的に質の高いフィードバックが得られやすいと思っています。

「レシピ」と「買い物」を組み合わせた価値を追求していくためには、瞬発的な検証だけでなく、日常的に使ってなるべく深い発見を重ねて、両者で補完しながらユーザー理解を精緻化していくことが一層重要ではないかと考えていました。

そのためには、実際のアプリを使って本番といえる環境で素早く検証を積み重ねられることが必要になります。

UI の「作って壊し」をやりやすく

では、どうすれば素早くアプリを本番で試せるのでしょうか。今回の場合、UI 実装を速くすることが効果的に思えました。

買い物機能は完全に新規のアプリケーションというわけでもないので、バックエンドはクックパッドマートのAPIを使えるなど、基礎部分がある程度固まっていました。そのため、レシピアプリ側でまず最低限使える状態にした後、改善を繰り返す期間が焦点となってきます。

そのタイミングで最も変動が大きいのは UI ではないかと思います。

f:id:y_f:20210110183725p:plain
使うデータは同じでも、目的に応じて見せ方は変わる

サーバーから返す情報が同じままでも、見せ方を変えるだけで印象や体験は変えられます。時には API からまるごと変える場合もありますが、多くは UI(View 層)の作り直しが楽になるだけでも実装は速くなります。

このように UI の「作って壊し」を頻繁に繰り返しやすい仕組みを用意したいと考える中で、SwiftUI が候補の一つとして挙がりました。

2. SwiftUI のリスクを抑えつつ導入できる見込みがあった

クックパッド iOS アプリでは 2メジャーバージョンをサポート

f:id:y_f:20210111210457p:plain
クックパッド iOS アプリの対応方針

買い物機能のリリース時期は2020年後半を予定していました。例年通りなら iOS 14 がリリースされる頃です。クックパッド iOS アプリは最新2メジャーバージョンをサポートする方針で、iOS 13 と 14 に向けた体制となります。そのため、iOS 12 以下で SwiftUI を使えないことはそれほど大きな問題ではありませんでした。

機能・画面単位で切り分けやすいアーキテクチャ

f:id:y_f:20210109220259j:plain
クックパッド iOS アプリの1画面のアーキテクチャ(https://logmi.jp/tech/articles/321186

クックパッド iOS アプリでは数年前から VIPER ベースの Layered Architecture を採用しており、責務ごとにしっかりと実装が分かれていました。また、マルチモジュール化*5も進めており、大きな機能はモジュール単位で分離されているため、他の機能開発にも影響せず実装を進めやすいという点もありました。一部だけ SwiftUI を導入するのも容易な環境だったと言えます。

【方針】View 層のみで SwiftUI を部分的に導入する

これらの状況を総合して、メリットを生かしつつリスクを最低限に抑えられる形であれば、SwiftUI を導入するのは良い選択だと考えました。

具体的には、既存の VIPER アーキテクチャに適合したまま、View 層のみで SwiftUI を使うという形です。SwiftUI には画面遷移を行うための NavigationView などのコンポーネントもありますが、それらは使わずにあくまで素朴な UI コンポーネントのみを使います。

  • 使う例:Button, Text, VStack, HStack, ZStack, ScrollView
  • 使わない例:NavigationView, List, Form, TextField*6

画面単位で完全に切り分けられているため、もし SwiftUI で実装に困難が生じたらすぐにそこだけでも UIKit に戻せるというリスク対策になっています。

実装

既存のVIPER アーキテクチャへの SwiftUI の組み込み

f:id:y_f:20210109215708p:plain
SwiftUI を組み込んだVIPER View 層の概略図

基本的には、画面(VIPERシーン)ごとに従来通り UIViewControllerがあり、そこに橋渡し役の UIHostingControllerを介して、SwiftUI で書かれた Viewが置かれる形です。

ここで、SwiftUI.View へデータを流し込むためには DataSource、SwiftUI.View からユーザー操作等のイベントを伝えるためには Delegateをそれぞれ用意して渡しています(詳しくは後述)。

finalclassKaimonoCartViewController:UIViewController,
    KaimonoCartViewProtocol, 
    KaimonoCartViewDelegate {

    ……

    privatevardataSource:KaimonoCartView.DataSource= .init()

    overridepublicfuncviewDidLoad() {
        super.viewDidLoad()

        letrootView= KaimonoCartView(delegate:self, dataSource:dataSource) 
      lethostingVC= UIHostingController(rootView:rootView)
        addChild(hostingVC)
        hostingVC.didMove(toParent:self)
        view.addSubview(hostingVC.view)
        hostingVC.view.translatesAutoresizingMaskIntoConstraints =false
        hostingVC.view.al.pinEdgesToSuperview() // 内製の Auto Layout helper ……
    }
}

UIViewController から SwiftUI.View へデータを流し込む

VIPER アーキテクチャで Interactor → Presenter → ViewController と渡ってくるデータを、 SwiftUI で書かれた View 側に伝える際には ObservableObjectを使っています。

https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

本稿のコード例では DataSource と名付けています。View で必要なデータは @Publishedをつけたプロパティとして、DataSource に定義しておきます。

structKaimonoCartView:View {
    classDataSource:ObservableObject, ReactiveCompatible {
        @PublishedvarisOrderProcessing:Bool=false@PublishedvarisLoadingCartProducts:Bool=true@PublishedvarcartProducts:[CartProduct]= []
        ……
    }
   
    weak vardelegate:KaimonoCartViewDelegate?
    @ObservedObjectvardataSource:DataSource……

    varbody:some View { 
       ……
    }
}

その上で、DataSource は親の ViewController で所有*7して、View からは @ObservedObject property wrapper でそれを監視させます。

クックパッド iOS アプリでは VIPER のデータフローに RxSwift を利用しているため、ReactiveCompatibleプロトコルにも適合させて以下のように繋ぎました。

// KaimonoCartViewController.swift

    presenter.cartProducts
        .drive(dataSource.rx[\.cartProducts])
        .disposed(by:disposeBag)

    presenter.isLoadingCartProducts
        .drive(dataSource.rx[\.isLoadingCartProducts])
        .disposed(by:disposeBag)

    presenter.isOrderProcessing
        .drive(dataSource.rx[\.isOrderProcessing])
        .disposed(by:disposeBag)

このように ViewController の DataSource へ最新の値を流し込めば、あとは値の変化に応じて自動的に View が再描画されます。

SwiftUI.View から UIViewController へイベントを伝える

View で起きたイベントを ViewController に伝える際には Delegate を使いました。

基本的には UIKit などの命名規則と合わせて、Delegate の呼び出し元の名前に動詞を続ける命名にしています。ただし、第一引数で self を渡すのはやめています*8

// KaimonoCartView.swiftprotocolKaimonoCartViewDelegate:AnyObject {
    funckaimonoCartViewDidTapProductTile(productID:Product.ID)
    funckaimonoCartViewDidTapTermsLawButton()
    funckaimonoCartViewDidTapOrderButton()
    ……
}
// KaimonoCartView.swiftprivatevarfooter:some View { 
        HStack(alignment: .center, spacing:0) {
            ……
            Button(action: {
                delegate?.kaimonoCartViewDidTapOrderButton()
            }, label: {
                Text("注文する")
                ……
            })
        }
    }
// KaimonoCartViewController.swiftfunckaimonoCartViewDidTapOrderButton(){
        // 注文処理のトリガーに必要な値を流す
    }

SwiftUI で実際どうだったか

よかった点:開発効率の向上

まず、UI の組み立ては期待通り快適で素早くできました。その様子は Apple 公式のチュートリアルでもおわかりいただけると思うので割愛します。ここでは実際に UI を作り込んでいく中で良かったと感じた点をご紹介します。

1. 複雑・多様な状態のある画面実装が楽

買い物機能では、サービスの特性上さまざまな要因で表示内容を変える必要があります。 (例:受け取り場所の設定状況、注文締切、受け取りのタイミングなど)

f:id:y_f:20210109230314p:plain
かいものタブ トップ画面のパターン例(まだまだ沢山ある)

従来なら UITableView や UICollectionView を使った実装を考えるところですが、セルやレイアウトの定義、更新タイミングなど考慮すべきこともコード量も多くなります。複雑度が増すほど不具合も生んでしまいやすく、この状況では画面構成を色々と変えてみたくても実装者のフットワークは重くなります。

これに対して、SwiftUI では表示条件をそのままシンプルに書けば良く、データが更新された時の再描画も任せておけます。

具体例1:かいものタブ トップ画面

例えば以下の要件があるとします。

  • 過去に1件以上注文があるなら「最近の買い物を見る」導線を表示したい
    • 配送済み かつ 未受け取りの品があるなら それを「受け取り可能なご注文があります」に変えたい
  • 過去に1件以上注文があるが 配送時のプッシュ通知がオフ なら、通知設定導線を表示したい

View は次のように表現できます。

// KaimonoTopView.swiftvarbody:some View {
        ScrollView {
            VStack(alignment: .leading, spacing:16) {
                if dataSource.isLoadingDeliveries {
                    ActivityIndicator()
                } else {
                    if dataSource.hasLeastOneOrder {
                        if dataSource.hasAcceptableDeliveries {
                            acceptableDeliveriesRow // 「受け取り可能なご注文があります」
                        } else {
                            normalDeliveriesRow // 「最近の買い物を見る」
                        }

                        if dataSource.isPushNotificationDenied {
                            pushNotificationDeniedRow // 通知設定導線
                        }
                    }
                }
                ……
            }
        }
    }

実際にはこれ以外にも様々な要件があり、かなり複雑な画面です。しかし、SwiftUI なら素朴に条件を並べるだけで構成できます。コードを見て仕様把握しやすく、改変したいときも素直に変えるだけで済むので保守性が高いと感じました。

具体例2:カート画面

SwiftUI の宣言的な表現の良さは、以下の場面でも実感できました。

  • 注文ボタンが押されたら、注文処理が完了するまではボタンを無効化しておきたい
    • 変更操作や画面遷移をしないように「品数の変更」や「よくある質問」などのボタンも一通り無効化しておきたい
  • もし注文処理が通信エラー等で中断されたら、再度押せるように有効化したい

この要件を .disabled(_:) modifier 1行で対応できるのはとても心地よいと感じます。

https://developer.apple.com/documentation/swiftui/list/disabled(_:)

// KaimonoCartView.swift// 注文処理中だけ、ボタンを無効化したいvarbody:some View {
        ZStack {
            VStack(spacing:0) {
                if dataSource.isLoadingCart {
                    ActivityIndicator()
                } else {
                    ScrollView {
                        paymentSettingRow
                        deliveryInformationRow
                        deliveryTutorialsRow
                        notesRow
                        faqRow
                    }
                    Divider()
                    footer
                }
            }
            .disabled(dataSource.isOrderProcessing)
            // ↑この1行で、この VStack 内の Button などはすべて良い感じに disabled になるif dataSource.isOrderProcessing {
                ActivityIndicatorToast()
            }
        }
    }

2. UI コンポーネントの取り回しが楽

f:id:y_f:20210112162123p:plain
同じ「商品タイル」を使う画面例

1つの UI コンポーネントを複数画面で使い回すことはよくあると思います。 UIKit でも使い回し自体は可能でしたが、SwiftUI ではより扱いやすいと感じました。

2.1 柔軟に移植や組み合わせができる

例えば買い物機能では、商品タイル ProductTileという UI コンポーネントを多用しています。

structProductTile:View {
    varproduct:ProductvardidTapImage: ((_ productID:Int64) ->Void)
    vardidTapAddToCartButton: ((_ productID:Int64) ->Void)

    varbody:some View {
       ……
    }
}

f:id:y_f:20210113115003p:plain
商品タイルのパターン例

商品の状態によって「NEW」「数量限定」などのラベルが表示されます。もし選択中の受け取り日にこの商品の配送がなければ「直近でお届けできる日を確認」、在庫がなければ「売り切れ」などのオーバーレイも表示されます。

この ProductTileを機能させるには、少なくとも以下の3つを与える必要があります。

  • 商品データ product
  • 商品サムネイル画像をタップされたときの挙動 didTapImage
  • カート追加ボタンをタップされたときの挙動 didTapAddToCartButton

これは親 View から渡します。

f:id:y_f:20210113122728p:plain
UI コンポーネントの依存の概略図(例:店舗詳細画面)

上図は、商品グリッド表示の ProductsGridを埋め込んだ店舗詳細画面 KaimonoShopDetailViewの例です。ProductsGridを通して ProductTileはグリッド状に表示されます。

structProductsGrid:View {
    varproducts:[Product]vardidTapImage: ((_ productID:Int64) ->Void)
    vardidTapAddToCartButton: ((_ productID:Int64) ->Void)

    ………

    varbody:some View {
        ForEach(0..< rows.count, id: \.self) { index in
            HStack(alignment: .top, spacing:gridSpacing) {
                ForEach(rows[index]) { product in// ProductTile に必要なものは initializer の引数に示されている。// 使う側はそれを用意して渡せば完了。
                    ProductTile(
                        product:product,
                        didTapImage:didTapImage,
                        didTapAddToCartButton:didTapAddToCartButton
                    )
                }
            }
        }
    }
}

前述の説明とも関連しますが、買い物機能の SwiftUI 実装では 親 View から 子 View へ必要なデータを与えて、子 View で起きたイベントの処理は親に委譲される構造にしました。

つまり、依存は親から注入されるので UI コンポーネント自体はどれも無垢です。その画面特有の都合などは最終的に委譲された先にたどり着く ViewController の実装が担います。そのため、UI コンポーネント自体はどの画面にもほぼコピー&ペーストですぐ移植できますし、互いに組み合わせたり入れ込むこともたやすくできます。

異なる UI コンポーネントに置き換えたいときも、同じものに依存しているなら変更箇所はごくわずかで済みます。

UIKit でも扱いやすい UI コンポーネントを作ることは可能でしたが、SwiftUI.View の struct ならより手軽に用意できる印象があります。

2.2 後から細分化もしやすい

必要なタイミングで「改変して独立」しやすくなっているとも感じました。

UI コンポーネントは細かすぎても荒すぎても不便ですし、開発状況の変化によって望ましい形は変わっていきます。最初は複数画面で同一でも良かった UI コンポーネントも、別々の道を歩みはじめたくなるケースはよくあります。

そのように分離して独立させたいとき、UIKit では Interface Builder (.xib, .storyboard) を使う場合でもすべてコードで書く場合でも Auto Layout の制約*9などに気を配る必要がありました。また、もし古い IBOutlet の紐付きを見落とせば、アプリのクラッシュの原因*10にもなってしまいます。

SwiftUI では前述の依存の構造とも相まってそのようなしがらみがなく、一部だけ抽出して独立させるのもかなり簡単だと思えました。

最初から粒度を気にせずに UI を作り始められて、後で必要になったときに結合・分離に柔軟に対応できるのは魅力的です。

3. スタイル調整も楽

スタイルを調整しやすいのも良い点です。よく使うシャドウや角丸に加えて、マージン調整、文字の装飾なども UIKit に比べて簡潔で扱いやすくなっています。細部までこだわった表現も簡単にできて、デザイナーと一緒にベストな実装を模索できました。

デザイナーも Pull Request を

f:id:y_f:20210110190723p:plain
デザイナー自身で細部を調整した Pull Request

同じチームのデザイナー @sn_taigaさんが自らスタイル調整の Pull Request を出してくれることが何度かありました。

UI を微調整したいとき、エンジニアとデザイナーの間で指示・実装・修正確認のやりとりが何度も発生することもあると思います。もしデザイナーが直接修正できるなら、オーバーヘッドを減らして時間を短縮できます。

従来の UIKit に比べて分かりやすく、iOS エンジニアでなくても簡単に修正できる部分が増えていくのは大きな進歩だと感じます。

困った点:余計な苦労もある

メリットもありましたが、予想通り SwiftUI を使うことで苦労した点もあります。

1. 不具合

iOS 13.0〜13.4 あたりでは「それはないでしょ……」という不具合もありました。一つ一つ対応策はあって地道に対処していくことになるのですが、それだけでもう一記事かけそうなので、ここでは一例だけ紹介します。

例:ScrollView 内の Button が、スクロールのためのタップで誤動作してしまう

iOS 13 の初期の頃に実機で起きる不具合で、動作確認中にショックを受けました。これは少なくとも iOS 13.5.1 以降*11では修正済みのようです。買い物機能ではワークアラウンドとして .onTapGestureを代わりに使っており、時期を見て Buttonに戻していく予定です。iOS バージョンによって分岐する Button 用コンポーネントを用意する案も検討しています。

    ScrollView {
        VStack(alignment: .leading) {
            ……
            CartSectionHeader(title:"受け取り情報")
            PickupNameRow(pickupName:dataSource.pickupName)
                .onTapGesture(perform:didTapPickupNameRow)
                // Button はスクロール時のタップで誤動作することがあるので、代わりに .onTapGesture を使う
            Divider()            
            ……
        }
    }

2. 機能不足

iOS 14 からは必要なものが大分揃った印象がありますが、iOS 13 の SwiftUI では「あってほしい……」と思う機能が足りません。想定していた通り UIKit で代用するか、工夫したコンポーネントを用意するなどの対応が必要になります。

例1:ScrollView の contentOffset の get / set

ボタンをタップしたら指定位置まで自動でスクロールしたい、といった要件はよくあります。しかし、iOS 13 の SwiftUI の ScrollViewではスクロール位置の設定が UIKit のように簡単にはできません。iOS 14 からは ScrollViewReaderScrollViewProxyが登場してできるようになりました。

https://developer.apple.com/documentation/swiftui/scrollviewproxy

例2:読みやすい幅対応

クックパッド iOS アプリは iPad にも対応しており、読みやすい幅を考慮した UI にしています。

techlife.cookpad.com

Auto Layout では readableContentGuideのような仕組みが用意されていましたが、iOS 13 の SwiftUI では公式に用意された仕組みを見つけることができませんでした。

そのため GeometryReaderと 与えられた画面幅に対して読みやすい幅を返す ApproximateReadableContentというヘルパー(社内製)を組み合わせた ReadableScrollViewを用意して対応しました。

structReadableScrollView<Content: View>:View {
    varcontent:Contentvarbody:some View {
        GeometryReader { geometry in
            ScrollView {
                HStack(alignment: .top, spacing:0) {
                    Spacer()
                    VStack(alignment: .leading, spacing:0) {
                        self.content
                    }.frame(width:ApproximateReadableContent.maximumWidthInsideMargins(availableWidth:geometry.size.width))
                    Spacer()
                }
            }
        }
    }
}

他には

@Environment(\.horizontalSizeClass) varsizeClass

をもとに適切な padding を設定するという方法もあるようです。

例3:遅延読み込み

VStack 内に並べる項目数が非常に多くなると、画面描画がカクカクしはじめるなどパフォーマンス上の問題が出てきます。

見えない部分まですべて描画するのではなく、スクロールに合わせて逐次構築していく「遅延読み込み」を実現したいところですが、iOS 13 の SwiftUI ではまだ便利な仕組みが用意されていません。項目数や構成を減らして軽くするか、従来の UICollectionView などに置き換えるかといった選択肢になります。

iOS 14 からは LazyVStack / LazyHStackのように欲しかったものが揃ってきました。詳しくは WWDC 2020 の Stacks, Grids, and Outlines in SwiftUIをご参照ください。

developer.apple.com

まとめ

SwiftUI の導入によって、総合的には開発体験と効率は向上できたと思っています。一度慣れれば新規の UI 実装も素早くでき、改変も楽にできます。何より楽しく開発できるという点で、SwiftUI を採用して良かったと感じます。

iOS 13 の初期バージョンの SwiftUI にはまだ困る面もあります。ただそれは今後最新の iOS が普及するにつれて解決する問題だと思っています。現状では QA 体制・自動テストの工夫で問題に気づけるようにし、事業的な優先度と実装の難易度を把握して、適宜 UIKit を使うなどの判断ができる体制で付き合っていく必要はあります。

チームで「改善していける」良い雰囲気づくり

開発体験の向上については、アプリ実装の素早さがチームの雰囲気づくりにも貢献できた点があると感じています。

デザイナーと話しながら、色々なパターンを素早く実際に作って、本番データでアプリの挙動をすぐに試してみることも可能でした。ミーティングで出たアイデアをすぐに具体化してみて、UI 面の課題やコンテンツ面・運用上の問題に気づいて軌道修正する、そのように勢いよく改善が進んでいくと、開発のモチベーションも上がると思っています。

f:id:y_f:20210112171601p:plain
開発チームの Slack の盛り上がりの様子

このように楽しく素早い開発ができる環境を整え、チームで次々と改善していける良い雰囲気づくりができた点でも一定の成果はあったと感じています。

一方で、現時点のユーザー体験はまだまだ理想形にはほど遠い状態です。 2021年はこの作り変えやすい土台を活かして、「レシピ」と「買い物」が融合した圧倒的に良い体験を探っていきたいと考えています。

クックパッドでは仲間を募集しています!

今回は買い物機能の開発にあたっての技術選定や SwiftUI の活用事例についてご紹介しました。

買い物機能の取り組みにご興味を持ってくださった方は、プロダクトマネージャー @naganyoさんの1年の振り返りの記事もぜひご一読ください。 note.com

クックパッドでは技術を活用してサービスや事業を推進していきたい方を大募集中です!

iOS・Android・Web フロントエンド、サーバーサイド(Ruby on Rails, Java 等)、検索技術、ログ分析、マーケティングなどなど様々な領域で取り組みたい課題が沢山あります。 カジュアル面談や学生インターンシップなども随時実施していますので、ぜひお気軽にご連絡ください!

info.cookpad.com

cookpad-mart-careers.studio.site

*1:近隣地域の生産者や市場直送の新鮮でおいしい食材を、1品から送料無料で購入できる。https://info.cookpad.com/pr/news/press_2020_1015

*2:最短で注文当日に受け取り可能。https://www.youtube.com/watch?v=FIhAFjVmS10

*3:より正確には「Apple プラットフォーム向けのアプリ」で、iOS や macOS に限らず tvOS や watchOS のアプリも作れる。https://developer.apple.com/xcode/swiftui/

*4:最初のコミットは2012年の8月、2020年12月末時点では6万1,000以上のコミットを経て、24万1,800行のコードベースがある。

*5:クックパッドのエンジニアが語る、巨大で歴史あるアプリにおける破壊と創造 - ログミーTech https://logmi.jp/tech/articles/321186

*6:検証当時、NavigationView は動作が不安定な部分があったため。List, Form はカスタマイズ性に乏しく、TextField も日本語変換時の動作が怪しかったため。

*7:@ObservedObject は iOS 13.3 未満あたりで値を弱参照していて、ふとした拍子に消える可能性があったので ViewController 側に持たせるようにしている。

*8:UIKit では UI 操作などのために外部に提供するインターフェイスとなるが、SwiftUI では ObservableObject のように別の形が提供されているので基本的には不要なはず。むしろ SwiftUI のスコープ外から意図しない操作を可能にしてクラッシュさせる恐れもある。ここではそれを回避したいため。

*9:既存実装では Auto Layout の制約がからまっていて読み解きが大変だったり、結局一度消してすべて付け直したりすることもよくある。UIStackView によって改善されたものの、後から一部を抜き出すときはやや煩雑な印象がある。

*10:コードと xib の両方から消したつもりで、xib 側に一部残っていると実行時エラーでクラッシュする。

*11:いつの間にか直っていた。実機のみで起きる不具合で、アップデート後は iOS 13.0 〜13.4 の実機が手に入りづらく、修正された正確なバージョンはわからない。

Cookpad Online Spring Internship 2021 を開催します!

$
0
0

ユーザー・決済基盤部の三吉です。昨年よりエンジニアの立場から新卒採用を担当しています。

この記事では、3月下旬に開催するスプリングインターンシップについて紹介します。 インターンシップの実施要項や応募フォームは下記のページよりご確認ください。

クックパッドでは、スプリングインターンシップを毎年開催しています。 しかし、その形式は年々改善を重ね変わってきています。 昨年は初のオンライン開催でした。 今年もオンラインでの開催ですが、社内ハッカソン "Hackarade"の形式を取り入れた新しいものになっています。

また、昨年のテーマはサーバサイドアプリケーションのパフォーマンスチューニングでしたが、今年はモダン Web フロントエンドがテーマです。

クックパッドの社内ハッカソン "Hackarade"を体験してみよう!

クックパッドでは年に数回、エンジニア全員参加の社内ハッカソン "Hackarade"を開催しています。 Hackarade は単なるハッカソンとちがい、エンジニア全員で新技術に触れることが目的です。 そのため毎回テーマを変えており、社内の第一人者による講義を受けたのち開発に取り組みます。 詳しい様子は以下の開催レポートをご覧ください。

もともとの Hackarade は1日で完結するものですが、昨年のスプリングインターンシップでは1日は短すぎるという声をいただいたので、5日間の日程を2ターム用意しています。

  • 第1ターム: 3/15(月)〜3/19(金)
  • 第2ターム: 3/22(月)〜3/26(金)

5日間という期間を取っていますが、時間を合わせて集まるのは初日と最終日のみです。 初日にオリエンテーションを行い、最終日に成果発表を行います。 2日目から4日目は各自での開発の期間とし、時間の使い方は自由です。 研究室やバイトに行ったり、別のイベントに参加したりしていただいても構いません。 その間、Slack や必要に応じて Zoom で社員 TA が非同期的に質問の回答や開発のサポートを行う予定です。

オフィスに集まる形だと全日程の参加が前提なので、こういった形式が取れるのもオンラインならではかと思います。首都圏以外の地域や海外からの参加など、オフィスに来ることが難しい方でも、より参加しやすくなっています。 他にもオンラインならではの工夫を凝らしたいと考えています。

実践! モダン Web フロントエンド開発

Hackarade では毎回テーマを変えていると書きましたが、今回のテーマは「実践! モダン Web フロントエンド開発」です。 技術部の外村 @hokacchaが講師を務めます。 TypeScript、React、Next.js、GraphQL など、クックパッドでも利用している Web フロントエンドの技術について実践を通して学びます。 参加に際して Web フロントエンドの知識は問いませんが、これらの技術にすでに親しみのある学生も(もちろんそうでない学生も)楽しめる内容になるはずです。

クックパッドのフロントエンド事情については、外村による以下の記事をご覧ください。 この記事をきっかけに今回のテーマが決まりました。


スプリングインターンシップの応募締切は 2/26(金)です。 選考フローは、書類選考とプログラミング課題のみのシンプルなものになっています。

学生の皆さまのご応募をお待ちしています!

Rails に Babel と Rollup を組み込んで CoffeeScript を JavaScript に段階的に移行した話

$
0
0

こんにちは。技術部クックパッドサービス基盤グループの青沼です。当グループではクックパッドのレシピサービスを支える web アプリケーションの改善を進めています。今回はフロントエンドの改善の一環として、 Babel と Rollup を Rails のアセットパイプラインに組み込み、レガシーな CoffeeScript ファイルを ES2015+ の JavaScript に移行した話をします。

レシピサービスと CoffeeScript の歴史

クックパッドは10年以上の歴史を持つサービスです。中でもレシピサービスの web アプリケーションは初期に作られた Rails 2 アプリケーションがアップグレードを重ねながら今も動いています。2018年には Rails 3 から4へ、つい最近では4から5へのアップグレードを完了しました。 Ruby のコードはそれに伴って新しい書き方へと徐々に移行されてきましたが、「壊れていないものは直すな」という言葉もあるように昔から姿を変えていないコードもあります。その一角がビューで使われる CoffeeScript のコードです。

CoffeeScript はいわゆる AltJS (JavaScript に変換される言語)のはしりで、2012年ごろに流行しました。当時の JavaScript は今のように毎年仕様改訂されることがなく、進化が止まったように見えていました。その停滞した空気に新鮮な風を吹き込んだのが CoffeeScript です。Ruby や Python や Haskell から影響を受けた簡潔な記法を取り入れ、リスト内包表記などの新しい言語機能を実装しました。今では JavaScript に取り入れられているスプレッド構文 [1, 2, ...rest]やアロー関数 () => {}は CoffeeScript が実装した機能から影響を受けています。Rails はバージョン3.1から CoffeeScript をフレームワークに統合し、新しい JavaScript コードは CoffeScript で書くように推奨していました。レシピサービスでも CoffeeScript のコードが多く書かれました。

いっときブームを起こした CoffeeScript でしたが、 JavaScript 自体がそれなりに書きやすく進化し、 TypeScript など有力な AltJS が台頭してきた現在では、積極的に採用する理由はほとんどなくなってしまいました。むしろ「JavaScript や AltJS の進歩から取り残されている」「既存のコードを変更するために CoffeeScript を覚える・覚え直すのが面倒」「Rails 自体がもはや CoffeeScript に力を入れていない」などのデメリットが目立つようになっています。

さらば CoffeeScript

そこで、2019年12月、レシピサービスに残る CoffeeScript をすべて JavaScript に変換することを決めました。元々 JavaScript に変換することが前提の言語ですから、変換自体は特に難しいものではありません。ただし純正のコンパイラの出力はコメント行が残っていなかったりと読みやすさを優先したものではないので、素直で読みやすい JavaScript を生成する別のコンパイラ、 decaffeinateを使うことにしました。(脱 CoffeeScript を支援するツールにデカフェと名付けるセンスがいいですね。)実際にはファイルひとつひとつを手作業で変換していくのではなく、一括変換を実行してくれるサポートツール bulk-decaffeinateを使います。 bulk-decaffeinate は以下の順番で変換対象に指定したファイル群を処理します。

  1. ファイルをエラーなく変換できるかをまずチェック。エラーがあれば中断
  2. ファイルをコピーしてバックアップを作成
  3. 拡張子を .coffee から .js に変更して Git にコミット
  4. ファイルを decaffeinate で JavaScript に変換し、上書き保存して Git にコミット
  5. 変換後のファイルに ESLint を適用して整形し、上書き保存して Git にコミット

拡張子の変更だけを先に行ってコミットするのは地味ですが重要なポイントです。内容の変更とファイル名の変更を1つのコミットに混ぜてしまうと、 Git は変更前後のファイルの関係を推測できず、変更前のファイルが削除されて新しいファイルが追加されたものとみなします。これではファイルの履歴が途切れたように見えてしまいます。内容の変更とファイル名の変更を別のコミットに分ければ Git はファイルの履歴を正しく推測してくれます。(Git がコミットとして記録するのはファイルツリーのスナップショットだけです。ファイル名が変更されたことを表すデータは存在しません。2つのコミットのスナップショットを比較して、同じかほとんど違いがない別名のファイルがあれば、ファイル名が変更されたとみなして表示します。)

サポートブラウザの問題

2020年1月から、 bulk-decaffeinate を使ってページや機能ごとの単位で徐々に変換を進めていきました。変換後の JavaScript をそのまま使えるなら楽なのですが、残念ながら一筋縄ではいかないことが分かりました。変換作業を行った当時のレシピサービスは InternetExploler 9 をサポートしていたからです。(今では IE 11 までのサポートになりましたが、これでもまだレガシーですね。) decaffeinate が書き出す JavaScriptは IE 9 で動かない ES2015 以降の新しい構文を含みます:

function foo() {const x = 1; // const は使えないvar y;
  for (y of [1, 2, 3]) { ... }// for-of は使えない

  sendAPIRequest('/example', (result) => {// アロー関数は使えないif ([4, 5, 6].includes(result.items)) { ... }// Array.includes は使えない});
}

これらを手作業で書き換えることもできますが、人間が新しい構文を見分けるのはミスを起こしがちですし、遠からずサポート対象外になる IE 9 のためだけに古い構文に書き直すのはもったいないものです。なんとか自動的に JavaScript を IE 9 に対応させられないだろうかと考えました。さて、 JavaScript を昔のブラウザに対応するよう変換するといえば Babel の出番です。 Babel を使えば新しい構文をターゲットのブラウザに対応した構文に変換することができます。

ところで、 JavaScript は仕様が改訂を重ねると構文の追加だけでなく新しいクラスやメソッドが追加されることもあります。それらはソースコードの変換で昔のブラウザに対応した形に書き換えることはできません。クラスやメソッドは動的に扱われる(たとえば実行時に組み立てた文字列を名前としてメソッドを呼び出すことができる)ので、ソースコードを静的に解析しただけでは網羅できないからです。昔のブラウザで動かそうとすれば、新しいクラスを再現する polyfill と呼ばれる一群のコードを追加で導入する必要があります。しかしながら、 IE 6 までサポートする巨大な polyfill を丸ごと入れてしまうと web ページのサイズとロード時間に影響してきます。かといって不要なパーツを除外していくのも手間がかかります。

幸いなことに、 decaffeinate が生成したコードで polyfill が必要になるのは Array.includes()ほか少しだけで、1万行のうち数十行程度でした。今回はそこだけ polyfill がいらない形に手作業で書き換えることで対処しました。

Babel をアセットパイプラインに組み込む

話を Babel に戻すと、 JavaScript をいつ Babel で変換するかという問題が出てきます。 Rails は Rails で JavaScript のアセットを変換するアセットパイプラインを持っています。 Rails アプリケーションをデプロイするときにアセットをコンパイルするタスクを実行すると、アセットパイプラインによって入力ディレクトリ以下の JavaScript ファイルが読み込まれ (CoffeeScript ファイルなら読み込んだ後に JavaScript に変換するプリプロセスが行われ)、 JavaScript 同士が結合され、最後に圧縮されて出力ディレクトリに書き出されます。また開発モードで Rails サーバを起動しているときは、入力ディレクトリ以下のファイルが監視され、編集されたアセットは自動的に再コンパイルされます。

この一連の処理に Babel を付け加えるなら、入力ディレクトリ内のファイルを先に書き換えるか、出力ディレクトリ内のファイルを後で書き換えるかです。どちらにせよ開発モードで自動再コンパイルのタイミングと干渉しないようにする必要があり、ちょっと面倒な予感がします。

ここでアセットパイプラインの処理をもう一度よく見てみると、「CoffeeScript ファイルなら読み込んだ後に JavaScript に変換するプリプロセス」が目にとまります。アセットパイプラインにはソースコードを別のソースコードへと変換する処理がすでに組み込まれているのです。この仕組みに乗せてしまえば、ビルドツールを用意しなくても CoffeScript が使えていたのと同様に、 Babel が裏で動いていることを意識しなくてよくなるはずです。

アセットパイプラインに新しい変換処理を組み込むには、 call(input)クラスメソッドを持つクラスを書いて Sprockets.register_postprocessorで登録するだけと、拍子抜けするほど簡単です。実際に書いたのがこのコードです:

# babel_processor.rbmoduleBabelProcessorBABEL_PATH = Shellwords.escape(Rails.root.join("node_modules/.bin/babel").to_s)

  classError< StandardError; enddefself.call(input)
    # data は JavaScript のソースコード文字列
    data = input[:data]

    if has_babel_pragma?(input)
      # data を babel コマンドの標準入力に流し込んで標準出力から結果を得る
      stdout, stderr, status = Open3.capture3("#{BABEL_PATH} --no-babelrc --no-highlight-code", stdin_data: data)
      raiseError, "in #{input[:filename]}: #{stderr}"unless status == 0
      data = stdout
    end

    { data: data }
  end# ファイルの先頭に // @babel か /* @babel */ があるときだけ変換defself.has_babel_pragma?(data)
    %r{\A\s* /[/*]\s* @babel\b}x.match?(data)
  endend# config/application.rb の中でSprockets.register_postprocessor 'application/javascript', ::BabelProcessor

BabelProcessorはひとつの変換処理を担当するアセットプロセッサとして振る舞うクラスです。 call(input)メソッドはファイルから読み込まれた JavaScript のファイル名とソースコード文字列を受け取ります。その文字列を babelコマンドの標準入力に渡し、変換後の文字列を標準出力から得て返すだけです。これをアセットパイプラインのポストプロセッサとして登録します。なお、アセットパイプラインのメイン処理で結合された後の JavaScript を扱うため、プリプロセッサではなくポストプロセッサにしています。また変換されることで壊れるコードがないとも限らないので、選ばれたファイルだけを変換するため、先頭に // @babelと書かれたファイルだけを変換するようにしています。

この仕組みはうまく動き、移行作業はスムーズに進みました。 bulk-decaffeinate によって出力された ES2015+ の JavaScript はほとんど修正の必要がなく、1ヶ月程度で合計1万行程度の CoffeeScript をすべて JavaScript に変換することができたのです。

JavaScript と CoffeeScript の行数の変化
JavaScript と CoffeeScript の行数の変化

CoffeeScript の行数が減るにつれて JavaScript の行数が増えています。(並行して不要な JavaScript を削除する作業も進めていたので2月13日あたりで行数が急激に減っていますが、無関係です。)

Babel から Rollup へ

CoffeeScript と jQuery で書かれていたコードが ES2015 になるともっと欲が出てきます。 Rails 独自の //= require foo.jsディレクティブで文字列的にファイルを結合するのをやめて import 'foo'require('foo')を使いたいし、 npm でインストールしたモジュールも使いたいし React と JSX でビューを書きたいし、そうなったら TypeScript も使いたい。

Rails 6 に統合された webpack を使えば実現できるのですが、あいにく Rails 4 では動きません。それにレシピサービスはページごとに異なる小さな JavaScript ファイルをいくつも読み込んでいて、コードをひとまとめにバンドルするのが基本の webpack とは相性が悪いのです。たとえば、ビューの部分テンプレートの中で特定の条件のときだけ <script>タグを差し込む以下のような処理が存在します:

/ _footer.haml.footer%pページのフッターです
  -if some_condition?
    %p条件に一致したときだけ表示される追加のフッターです
    / 追加のフッターにイベントハンドラを追加する JS= javascript_include_tag 'optional_footer.js'

この optional_footer.js をバンドルにまとめるとすると、条件に一致するときだけ処理を実行するようにロジックを変える必要があります。ひとつならともかく何十箇所もこんなコードがあるので修正の手間も馬鹿になりません。

なんとかならないかと思っていたら、モジュールバンドラの Rollup がコマンドラインで単一のファイルを変換できることに気づきました。 webpack よりは若干マイナーな存在ですが、つくりがシンプルなぶん設定が簡単で、コマンドとして実行しても動作が速いです。上記の BabelProcessorをベースに、 babelコマンドを呼ぶ箇所を rollupにし、いくつかの処理を加えました。またその処理に対応する Rollup の設定を rollup.config.js に書きました。

# rollup_processor.rbrequire"tempfile"moduleRollupProcessorROLLUP_PATH = Shellwords.escape(Rails.root.join("node_modules/.bin/rollup").to_s)
  ROLLUP_CONFIG_PATH = Shellwords.escape(Rails.root.join("rollup.config.js").to_s)

  classError< StandardError; enddefself.call(input)
    data = input[:data]
    if has_rollup_pragma?(data)
      self.build(input[:data], input[:filename])
    else
      { data: data }
    endenddefself.build(data, filename)
    dirname = File.dirname(filename)

    Tempfile.create("cookpad_all_rollup_processor") do |temp|
        stdout, stderr, status = Open3.capture3(
          { "COLLECT_MODULE_PATHS" => temp.path },
          "#{ROLLUP_PATH} --config #{ROLLUP_CONFIG_PATH} -",
          stdin_data: data,
          chdir: dirname,
        )
      raiseError, "in #{filename}: #{stderr}"unless status == 0# Rollup に渡した JavaScript から `require()` や `import` で読み込まれたファイルのパスを集め、# それらのファイルが依存関係にあることを Rails に伝える。
      module_paths = JSON.parse(temp.read)
      dependencies = self.dependencies_from_paths(module_paths, dirname)

      { data: stdout, dependencies: dependencies }
    endenddefself.dependencies_from_paths(paths, base_dir)
    node_modules_path = Rails.root.join("node_modules").to_s + "/"
    paths.reject do |path|
      path == "-" || path.start_with?("\0") || path.start_with?(node_modules_path)
    end.map do |path|
      realpath = File.realpath(path, base_dir)
      "file-digest://#{realpath}"endenddefself.has_rollup_pragma?(data)
    %r{\A\s* /[/*]\s* @rollup-entry-point\b}x.match?(data)
  endend# config/application.rb の中でSprockets.register_postprocessor 'application/javascript', ::RollupProcessor
// rollup.config.jsimport babel from '@rollup/plugin-babel';
import commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';

import fs from 'fs';
import path from 'path';

const collectModulePaths = {
  buildEnd() {if (process.env.COLLECT_MODULE_PATHS) {const modulePaths = Array.from(this.getModuleIds());
      fs.writeFileSync(
        process.env.COLLECT_MODULE_PATHS,
        JSON.stringify(modulePaths)
      );
    }},
};

exportdefault{
  output: { format: 'iife'},
  plugins: [
    commonjs(),
    resolve({
      browser: true,
      extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
    }),
    babel({
      configFile: path.resolve(__dirname, 'babel.config.js'),
      babelHelpers: 'bundled',
      extensions: ['.js', '.jsx', '.mjs', '.ts', '.tsx'],
    }),
    collectModulePaths,
  ],
};

追加した処理はアセット間の依存関係に関するものです。 Rails のアセットパイプラインでは、 JavaScript のファイルから別の JavaScript を読み込むディレクティブ //= requireを書くことができます。たとえば a.js から b.js を読み込むには

console.log('This is a.js');
//= require ./b.js
console.log('This is b.js');

と書き、生成される a.js の中身は

console.log('This is a.js');
console.log('This is b.js');

になります。このような //= requireが書いてある参照元は参照先に(a.js は b.js に)依存することになります。依存関係があることで、開発モードで Rails サーバを起動しているとき、 b.js をエディタで編集すると、 b.js だけでなく参照元の a.js も自動的に再コンパイルされます。

しかし、 Rollup をアセットプロセッサとして使うと依存関係が失われてしまう場合があります。たとえば Rollup で処理される c.js から d.js を読み込むには

console.log('This is c.js');
import'./d';
console.log('This is d.js');

と、 JavaScript の import文を使うこともできます。アセットパイプラインの視点で見ると、 rollupコマンドに c.js の中身を流し込んだら

console.log('This is c.js');
console.log('This is d.js');

が得られたということになります。 Rollup によって d.js がインポートされたことは知る由もないので、 c.js が d.js に依存するという情報は失われます。

そこで依存関係を正しく認識させるために Rollup のプラグインを書きました。 rollup.config.js の collectModulePathsの部分です。 Rollup はプラグインで簡単に拡張でき、ビルドのさまざまなフェーズに独自の差し込むことができます。 collectModulePathsプラグインはビルド後のフェーズでビルド中にインポートされたパスを集めてファイルに書き出します。それをアセットプロセッサ側で読み込み、依存関係データを構築しています。

こうして Rails に Rollup を組み込んだ結果、アセットパイプラインを前提に書かれた既存の JavaScript コードにまったく手を加えることなく Rollup の恩恵を受けられるようになりました。 JavaScript の中で他のスクリプトやモジュールを importすることができますし、そうしたければ //= requireディレクティブと混ぜて書くことさえできます。

import React from 'react';
//= require ./e.jsimport'./f';

何やら禍々しい見た目ですが、段階的にコードを改善していくには便利な仕組みです。

おわりに

CoffeeScript とお別れした話と、アセットパイプラインの一工夫でフロントエンド開発がちょっとモダン化した話をしました。レガシーな JavaScript コードを抱えた Rails アプリケーションを運用している皆さんの参考になれば幸いです。

クックパッドでは仲間を募集しています!

さて、歴史ある web サービスの改善は地道なものですが、ときにはエキサイティングで急激な変化もあります。クックパッドサービス基盤グループでは、まずスマートフォンブラウザ向けのレシピページから、 Next.js と GraphQL バックエンドを使って一から書き直すプロジェクトを進めています。実はすでに一部のスマホ向けレシピページは Next.js で表示されています。今後もさらに多くのページを改善していきますので、最新の web 技術をバリバリ使ったサービス開発に興味がある方も、レガシーコードをバタバタやっつけたい方も、ぜひお気軽にご連絡ください。

info.cookpad.com

形態素解析を行うだけのバッチをつくる

$
0
0

研究開発部の原島です。今日は表題の渋いバッチをつくった話をします。

あっちでも形態素解析、こっちでも形態素解析

みなさん、形態素解析してますか?してますよね?クックパッドでもさまざまなプロジェクトで形態素解析をしています。

いや、むしろ、しすぎです。プロジェクト A でレシピを解析し、プロジェクト B でもレシピを解析し、プロジェクト C でもレシピを解析し、... といった具合です。ちなみに、形態素解析(の結果)が必要なプロジェクトとしてはレシピの分類やレコメンド、各種分散表現(e.g., word2vec)や BERT の学習などがあります。

もちろん、最終的に得たい解析結果が違うのであれば問題ありません。しかし、私が見たかぎり、ほとんどの場合は同じ(もしくは、同じにできそう)でした。であれば、

  1. 解析器をインストール(→ Dockerfile を試行錯誤)
  2. 解析対象を取得(→ SQL を試行錯誤)
  3. 解析器を実行(→ クックパッドの場合は ECS や IAM の設定を試行錯誤)
  4. 解析結果を保存(→ 同様に S3 や RDS の設定を試行錯誤)

という一連の処理を各開発者が個別に行うのは非効率です。同じ解析器を使い、同じ解析対象(基本的には解析時の全レシピ)を集め、定期的に解析を行い、解析結果を簡単に使い回せるようにしたい。形態素解析が必要なプロジェクトが増えるにつれ、そういう想いが募っていました。

共通化は面倒だけど、難しい話ではない

こうした背景で、重すぎる腰を上げて、共通化を行うことにしました。こういう作業ってどうしても後回しになりがちですよね。各開発者は各プロジェクトを進めたいのであって、他のプロジェクトのことまでケアして共通化を行うのはなかなか面倒です。

一方、この話は技術的には大したものではありません。単に、「形態素解析を行うだけのバッチをつくる」というだけの話です。上の処理 1 から 4 を丁寧に行ない、各プロジェクトが使いやすい形で最終的な解析結果を残せば任務完了です。難しい話ではありません。

形態素解析を行うだけのバッチ

というわけで、そういったバッチをつくってみました。バッチの概観は下図のとおりです。以下では、バッチの各処理について、その詳細をお話しします。

f:id:jharashima:20210307203402j:plain

1. 解析器をインストール

していません。同僚の @himktがつくった konohaを使いました。konoha はさまざまな形態素解析器(e.g., MeCab、Sudachi、KyTea)のラッパーです。konoha で解析を行なう社内サーバがあったので、解析器のインストールや設定はこのサーバに委ねることにしました(なので、上図にも処理 1 は含まれていません)。なお、クックパッドで使っている解析器は MeCab です。

2. 解析対象を取得

解析対象はレシピ(のタイトルや紹介文、手順など)です。これは Redshift から取得しました。クックパッドではほとんどのデータが Redshift に集約されています。また、Queuery(きゅーり)という社内向けのシステムがあり、UNLOAD を使うことで Redshift に負荷をかけずに SELECT が実行できるようになっています。

今回は、Queuery をさらにラップした corter(かーたー)という社内向けの Python パッケージをつくりました。corter は COllect Recipe-related TExts from Redshift の略で、その名のとおり、レシピに関するテキストを Redshift から収集するためのものです。

以下は、corter でクックパッドの全レシピのタイトルを取得するコードです(レシピ ID はダミーです)。

from corter.agent import RecipeTitleAgent
agent = RecipeTitleAgent()
recipe_ids, titles = agent.collect()
print(recipe_ids[0], titles[0]) # => 12345, 'ナスの肉味噌炒め'

corter の内部には、クックパッドの機械学習エンジニアであれば誰もが試行錯誤したであろう SQL が押し込められています。公開済みのレシピを絞り込む WHERE の条件とか毎回忘れちゃうんですよね。

ちなみに、このような Python パッケージをつくったのにはもう一つ理由があります。生文(形態素解析されていない文)を使うプロジェクトでもこのパッケージを使いたかったからです。このようなプロジェクトとしては、たとえば、SentencePiece の学習などがあります。

3. 解析器を実行

これは簡単です。2 で集めたレシピを 1 で触れた解析サーバに投げているだけです。ただし、現在、クックパッドには全部で約 350 万品のレシピがあります。いくら MeCab が高速でも、全レシピを 1 並列で解析するのは時間がかかります。

そこで、ジョブ管理システム kuroko2の paralle_fork を使い、10 並列で解析を行うようにしています(正確には、処理 2 の段階で解析対象を 10 分割で取得しています)。これで、350 万品を解析対象としても、タイトルのような短いテキストであれば 5 分程度で、手順のような長いテキストでも 1 時間程度で解析が行えるようになりました。

4. 解析結果の保存

解析結果は Redshift に保存するようにしました。S3 や RDS などの選択肢もありましたが、2 でも触れたように、クックパッドではほとんどのデータが Redshift に集約されています。解析結果も Redshift に保存することで、他のデータと一緒に使いやすいようにしました。

解析結果を Redshift に保存するには、まず、3 の結果を S3 にアップロードします(上図 4-1)。次に、アップロードされた 10 個の解析結果をマージして、1 個の TSV をつくります(4-2)。そして、SQL バッチフレームワーク bricolageを使い、TSV の中身を Redshift に COPY します(4-3)。これで、数億行のロードでも 10 分弱で終わります。

最終的に、下表のようなテーブルに解析結果が保存されます。これはタイトルの解析結果を保存したテーブル(上図では recipe_title_words)です。一行が一単語です。主なカラムは position(単語の出現位置)と surface(表層形)、pos(品詞)、base(原形)です。analyzed_at は、その名のとおり、解析時刻です。

recipe_idpositionsurfacepos base analyzed_at
12345 0 ナス 名詞 ナス 2021-03-08 00:00:00
12345 1 助詞 2021-03-08 00:00:00
12345 2 名詞 2021-03-08 00:00:00
12345 3 味噌 名詞 味噌 2021-03-08 00:00:00
12345 4 炒め 動詞 炒める2021-03-08 00:00:00

2 から 4 を日次で実行

以上の処理 2 から 4(今回、1 はなにもしていません)を、デプロイツール hakoを使い、日次で実行しています。具体的には、ECS でコンテナを起動し、そこで処理 2 から 4 を実行しています。これにより、毎日、その日の時点で公開済みのすべてのレシピの解析結果が Redshift に保存されている状態にしています。

余談として、差分更新も考えました。つまり、前日に投稿(正確には公開)されたレシピの解析結果は追加し、修正されたレシピの解析結果は修正し、削除(正確には非公開)されたレシピの解析結果は削除することもできます。しかし、すべてのレシピを解析したところで大して時間がかからず、むしろ差分更新によって複雑性が増すデメリットの方が大きかったので、差分更新はやめました。

こうやって文書にすると、各処理は大変に見えるかもしれません。しかし、実際のところは既存の便利ツール(konoha、Queuery、kuroko2、bricolage、hako)を組み合わせただけで、そんなに大変ではありません。私が頑張ったことと言えば、corter をつくったことくらいでしょうか。これも Queuery のおかげでだいぶ楽をさせてもらいました。

解析結果を各プロジェクトで使う

さて、解析結果はさまざまなプロジェクトで使えなければ意味がありません。そこで、生文だけでなく解析結果も corter で取得できるようにしました。生文を取得するときと同様、裏では Queuery を使っており、UNLOAD でラップした SELECT 文を Redshift で実行しています。

以下は、クックパッドの全レシピのタイトルの解析結果を取得するコードです。解析結果はスペース区切りで返ってきます。オプションを変えることで、品詞で絞り込んだり、ストップワードを弾くこともできます。

from corter.agent import SegmentedRecipeTitleAgent
agent = SegmentedRecipeTitleAgent()
recipe_ids, segmented_titles = agent.collect()
print(recipe_ids[0], segmented_titles[0]) # => 12345, 'ナス の 肉 味噌 炒め'

解析結果が必要なプロジェクトは基本的に Python がベースです。上のコードのあとで schikt-learn なり gensim なり transformers なりを使えば、レシピの分類やレコメンド、各種分散表現や BERT の学習などがすぐに始められます。

こうして、当初の目的どおり、さまざまなプロジェクトで各開発者が個別に形態素解析を行うという事態が避けられるようになりました。

次は?

本エントリの最後に、次に取り組みたいことを三つほど挙げておきます。

一つ目は、今回 Redshift に保存した解析結果をまだ使えていないプロジェクトが残っているので、これらをなくすことです。基本的には、既存のコードを corter に置き換えていくだけです。一方、それだけでは済まないプロジェクトがあります。レシピ検索のインデキシングです。まさに形態素解析が重要なプロジェクトですが、レシピ検索周りはレガシーの巣窟なので、これを置き換えるには相当の時間と覚悟が必要です。

二つ目は、再学習した解析器を使うことです。クックパッドでは、昨年、500 品からなるレシピの解析済みコーパスをつくりました。このコーパスには形態素解析(と固有表現認識、構文解析)の正解データが含まれています。このコーパスで解析器を改善し、解析誤りを減らすことで、形態素解析が必要なすべてのプロジェクトを底上げしたいと考えています。解析済みコーパスと再学習については来週の言語処理学会の @himkt の発表もご覧ください。

三つ目は、Redshift MLを使うことです。Redshift ML、突然現れましたね。上でも述べたように、解析結果は Redshift に保存してあります。これらを特徴量としてモデルを学習し、Redshift 内で推論するというフローをつくれば、レシピの分類などのプロジェクトは大部分を Redshift に任せられるかも?と考えています。Redshift ML についてはまだ勉強不足なので、まずは勉強します。

さて、これらに取り組むだけでも大変ですが、クックパッドには他にも取り組みたいことがたくさんあります。「おもしろそう!」とか「やってみたい!」と思ってくださった方は、ぜひ、採用ページをご覧ください。ご応募をお待ちしております。

info.cookpad.com

Google Apps Script の拡張サービスの TypeScript 用型定義ファイルの自動生成

$
0
0

こんにちは、メディアプロダクト開発部の後藤(id:mtgto)です。

今回は Google Apps Script の28個の拡張サービスについて、 TypeScript 用の型定義ファイル (@types/google-apps-script) を、Web エディタのオートコンプリートマクロ用のデータから自動生成するプログラムを作成した話を紹介します。

Google Apps Script の紹介

読者の皆様はGoogle Apps Scriptはご存知でしょうか。名前は聞いたことがあるけど使ったことはあまりないという方が多いでしょうか。

Google Apps Script を使うことでドキュメント、スプレッドシート、スライド、フォームといった Google サービスのデータの取得・更新などを ECMAScript のプログラムから行うことができます。 例えば、

  • スプレッドシートのセルを読み込んでドキュメントに出力
  • Drive のファイル一覧を読み込んでスプレッドシートにリンク付きで出力
  • 外部 API にアクセスするスクリプトを一定周期ごとに動かす

このようなことを無料で行うことができます1

クックパッドでは Google Workspace をオフィススイートとして広く使用しており、スプレッドシートやドキュメントの自動化・マクロ実装などに Google Apps Script が利用されています。

Google Apps Scriptを普段の作業の改善に使うことも可能です。たとえば定例のテンプレートを google docs で管理してておき、次回の定例前にApps Scriptで雛形を追加する、といったこともECMAScriptを少し書くことで可能です。

私は以前Google Apps Scriptを利用して、期間と会議室の候補を入れると参加者の予定から空き時間&空き会議室を探してくれるウェブアプリを作りました。

f:id:mtgto:20210308214802p:plain
会議時間、参加者、会議室から、全員の空き時間と空き会議室を検索

f:id:mtgto:20210308215018p:plain
検索結果をクリックし、タイトルと説明を埋めて「登録」を押すとGoogle Calendarに予定が作られます

英語版のみですが、Codelabs に概要を知るためのチュートリアルも用意されています。 ざっくり Google Apps Script でできることの概要を知りたい方はちょうどいいかもしれません。

https://developers.google.com/apps-script/quickstart/fundamentals-codelabs?hl=ja#getting_started

Google Apps ScriptをTypeScriptで開発したい

Google Apps Scriptは前述の通り、ECMAScriptで記述する必要があります。 Webエディタも用意されていますが、ユニットテストやトランスパイルを必要とする場合、gitバージョン管理をしたいなどの場合にはGoogle謹製の claspを使うことでCLIからの開発が実現できます。

clasp + TypeScriptでGoogle Apps Scriptのプログラムを書く場合には、@types/google-apps-script npmパッケージを使うことでGoogle Apps ScriptのAPI定義を利用できます。

標準サービス (Built-in services) と拡張サービス (Advanced services)

Google Apps Script で利用できる Google のサービスには標準サービス (Built-in services) と拡張サービス (Advanced services) という分別があります。

標準サービスのうちでもCalendar, Slides, Driveなど様々なGoogleサービスを利用できますが、さらに拡張サービスを使うことでGoogleの公開APIをECMAScriptから利用することが可能になります。

拡張サービスじゃないとできないことの例としては、

  • 自分以外のメンバーのカレンダーの予定を取得
  • 組織内のメンバーや会議室の情報を取得 (Admin Directory)
  • YouTube、Analytics、BigQuery などの標準サービスの対象外サービスの公開APIの利用

などがあります。

先程紹介したメンバーと会議室の空き時間を調整するアプリでは自分以外のメンバーの予定を調べないとだめなので、拡張サービスの利用がどうしても必要でした。

標準サービス用のTypeScriptの型定義は id:motemenによってAPIリファレンスから自動生成することで用意されていたのですが、拡張サービスの型定義は残念ながら用意されていませんでした。

そこで私が拡張サービスの型定義の自動生成に挑戦することにしました。

https://github.com/mtgto/dts-google-apps-script-advanced

Webエディタの自動補完定義からの自動生成

Google Apps Scriptで利用できる拡張サービスの数は2021年現在 28、利用できる関数の数は 2913 にもなります。

拡張サービスは標準サービスと違って API ドキュメントも公開されていないためスクレイピングによるAPI定義の取得もできません。 そこでWebエディタの自動補完マクロ用に用意されているJSONPデータに注目しました。

Webエディタでは拡張サービスであっても関数一覧が自動補完されます。

f:id:mtgto:20210308215049p:plain
GAS のエディタでの自動補完

自動補完の実現のために拡張サービスごとの関数やクラス構成はJSONPをWebエディタが読み込むことで実現しているようでした。例えば拡張サービスのCalendarの自動補完JSONPですと、

{"1": {"1": "Calendar_v3",
    "2": [{"1": "Acl",
        "2": "Calendar_v3.Calendar.V3.Collection.AclCollection",
        "3": 1
      },
      {"1": "CalendarList",
        "2": "Calendar_v3.Calendar.V3.Collection.CalendarListCollection",
        "3": 1
      },
      {"1": "Calendars",
        "2": "Calendar_v3.Calendar.V3.Collection.CalendarsCollection",
        "3": 1
      },
      ...
      {"1": "Events",
        "2": "Calendar_v3.Calendar.V3.Collection.EventsCollection",
        "3": 1
      }],
    "3": [{"1": "newAclRule",
        "2": "Calendar_v3.Calendar.V3.Schema.AclRule",
        "6": "Create a new instance of AclRule"},
      ...

このようなデータが用意されています。2このJSONをダウンロードし、プログラム内部でTypeScriptのnamespaceとinterfaceの構造に変換し、最後にd.tsファイル形式で出力することができるようにしました。

namespace Calendar {namespace Collection {interface AclCollection {// Returns an access control rule.
            get(calendarId: string, ruleId: string): Schema.AclRule;// Returns an access control rule.
            get(calendarId: string, ruleId: string, optionalArgs: object, headers: object): Schema.AclRule;}}}

Google Apps Scriptの中がJavaScriptではないなにかで書かれているためか、基本データ型として numberの代わりに Integerとなっていたりするのでそのような型の変換を行うなどしてTypeScriptの型定義として忠実に再現しています。残念ながらJSONデータの中で名前しか公開されてないデータ構造を関数のレスポンスの型として使っていたりするので、そこは諦めて anyで定義しています。

私が最初に型定義ファイルの自動生成プログラムを書いたときには自分が利用したいCalendarとAdmin Directoryにだけ対応していればよかったので、その2つだけDefinitelyTypedにPull Requestを出してマージしてもらいました。 そのあとGoogle Apps Scriptの中の人と話したりしてなんやかんやあり、結局全ての拡張サービスに対応できるようにプログラムの修正をしました。

まとめ

Google Apps Scriptの拡張サービスのTypeScriptの型定義をWebエディタの自動補完用のデータから自動生成するプログラムを書いた話を紹介しました。

Google Workspaceをがっつり利用している会社や組織の場合、Google Apps Scriptの標準サービスだけでも色々なことができることはあると思いますが、さらに拡張サービスを使うことで実はこんなこともできる、みたいな展開があるかもしれません。 また小さいスクリプトをGoogle Apps ScriptのWebエディタから書くのもいいですが、clasp + @types/google-apps-scriptを使うことでCLIでのTypeScriptでの開発もしやすくなっています。

QiitaのGoogle Apps Scriptタグにもたくさん記事が投稿されていますので興味のある方はご覧ください。

クックパッドでは仲間を募集しています

クックパッドではモダンなフロントエンドの基盤作りレガシーなフロントエンドからの移行を始め、web開発に力を入れています。もしこの記事を見て興味がありましたらお気軽にお問い合わせください。

https://info.cookpad.com/careers/


  1. 注意:無料なのはQuota の範囲内の場合です。

  2. Googleアカウントでログインしてないとダウンロードできないため、28サービス分のJSONPのダウンロードを手動でやる羽目になって地味に面倒でした。

Xcode12時代のCarthageで起こった問題とXCFrameworkへの移行

$
0
0

Xcode12時代のCarthageで起こった問題とXCFrameworkへの移行

モバイル基盤部のhiragramです。こんにちは。

私たちは、iOS版クックパッドアプリの開発において、CocoaPodsとCarthageを併用して依存ライブラリを管理しています。しかし、Xcode12の時代がやってきて、Carthageによる依存ライブラリのビルドに問題が生じました。この記事では、どのような問題なのか、そしてどのように対処したのかを紹介します。

3行まとめ

  • Xcode12とCarthageの組み合わせで問題が生じた
  • CarthageからSwiftPMへの移行を模索したが、断念した
  • 2021年2月にリリースされたCarthage 0.37.0でXCFrameworkが正式にサポートされたので、それに移行した

Xcode12と、当時のバージョンのCarthageの組み合わせで生じる問題

Carthageは、中央集権的なCocoaPodsとは対照的に、gitリポジトリを直接指定する分散型のパッケージ管理ツールです。リポジトリに含まれるライブラリのプロジェクト設定を使って、iOSアプリに組み込めるフレームワークをビルドします。Mac上で動くiOSシミュレータと実機の両方で使えるようにするため、それぞれのCPUアーキテクチャのためのバイナリを lipoというツールで1つのバイナリにまとめて、XXX.framework というファイルを作ります。lipoで作られた、複数のアーキテクチャ向けのバイナリを含むものはファットバイナリと呼ばれます。

この仕組みはXcode11以前から用いられてきましたが、Xcode12のツールチェインを使って、従来の方法でライブラリをビルドしようとするとエラーが出て失敗するようになりました。これはXcode12になって、iOSシミュレータ用のバイナリが、従来のIntel製CPUのx86_64用バイナリに加えてApple Siliconのarm64用バイナリを含むようになったことに起因します。iPhone実機用とApple Silicon上のシミュレータ用のバイナリがいずれもarm64用で、lipoの制約によりその両方を1つのファットバイナリに含めることができないためです。

Carthage builds fail at xcrun lipo on Xcode 12 beta (3,4,5...) · Issue #3019 · Carthage/Carthage

こちらのissueでは、シミュレータ向けのビルドをするときに EXCLUDED_ARCHSにarm64を指定することで、 "Apple Silicon上で動くiOSシミュレータ"向けのコードをバイナリに含めないようにしてarm64(実機用)との衝突を防ぐ、というワークアラウンドが紹介されています。Xcodeのbetaが進むうちに根本的な対処法が見つかるだろうと楽観していましたが、Xcode12が正式にリリースされて以降もこのワークアラウンドが必要であるということがわかりました。そのため、クックパッドアプリもこのワークアラウンドを導入して開発/リリースを続ける必要がありました。

しかし、ワークアラウンドはあくまでその場しのぎですので、抜本的な対応として何をどうするべきか検討することにしました。

Swift Package Manager移行の模索

このセクションで書かれているのは2020年12月時点での状況です。

Carthageに囚われずパッケージ管理について改めて見つめ直す良い機会と捉えて、根本対応の手段を検討しました。その中で、XcodeのGUI上で扱うSwift Package Managerが候補にあがりました。サードパーティの依存管理ツールをハイブリッドで運用するよりも、公式が用意したもののほうが色々と都合がよいに違いありません。

Swift Package Manager(以下SwiftPM)は、Appleが提供する依存パッケージの管理ツールです。Xcode11からSwiftPMの機能が統合され、プロジェクトにSwiftPMのパッケージを含めることができるようになりました。Carthageで導入していた外部ライブラリを、SwiftPM経由で入れるように出来ないかを模索しました。

断念

結論から述べると、SwiftPMへの移行を断念しました。私たちの開発スタイルやプロジェクト構成にマッチしない都合があったからです。その中でもクリティカルだった、ユニットテストに関する問題を紹介します。

RxTestを使ったテストがビルドできない

クックパッドアプリではユニットテストの一部でRxTestを利用しており、リモートブランチを更新するごとにCI環境でテストがビルド/実行されます。しかし、RxSwiftをSwiftPMで導入したときに、RxTestを使っているテストコードがビルドできませんでした。シンボルの重複が起きているというエラーが出て、プロジェクトのビルド設定を変えたりアンブレラフレームワークを挟んだりしても解決できませんでした。RxSwiftのissueでも、多くの人がテストコードのビルドやライブラリのリンクなどについて問題を報告していて、SwiftPM側に問題があるという見解が示されています。

We unfortunately can't support SPM properly due to a critical known bug in SPM affecting many multi-target repos: https://bugs.swift.org/browse/SR-12303

- Migrating from Cocoapods to SPM, UITest target "missing required module 'RxCocoaRuntime'"· Issue #2210 · ReactiveX/RxSwift

上述のコメントからリンクされているバグチケットは報告から半年以上たった現時点でもSwiftPM開発メンバーからのリアクションが無く、私が手元で何かちょっと頑張った程度ではどうにもならないだろうと考え、SwiftPMへの移行を断念しました。

Carthage 0.37.0で新しく追加されたXCFrameworkへの移行

SwiftPMへの移行を模索していたのと同じ時期に、CarthageのリポジトリではXCFrameworkをサポートするための開発が進められていました。 XCFrameworkとは、Xcode11からサポートされた、フレームワークを配布するための新しい構造です。従来の形式では、複数のアーキテクチャのためのバイナリを1つのファットバイナリに統合していましたが、XCFrameworkは単体のプラットフォームのためのフレームワークを複数含む形式です。lipoによって1つのファットバイナリに統合する必要が無くなったため、記事の冒頭で述べたarm64のバイナリが衝突してしまう問題を回避できます。

XCFrameworkの詳細については、WWDC19のトークを御覧ください。

Binary Frameworks in Swift - WWDC19

Carthageでは0.37.0から、 --use-xcframeworksというオプションをつけることでXCFrameworkとしてビルドするようになりました。クックパッドアプリのプロジェクトでは0.37.0を使うことにして、Carthageで入れるライブラリはすべてXCFrameworkにすることにしました。

また、XCFrameworkの場合は従来Build Phaseに足す必要があった copy-frameworksが不要になります。もともと copy-frameworksは、AppStoreへサブミットするときにiOSシミュレータ向けのバイナリを含んでいると機械的にリジェクトされてしまうことに対する回避策でしたが、XCFrameworkは内部でプラットフォームが区別されているため、Xcodeの標準のコピーで事足りるようです。

クックパッドアプリではプロジェクトファイルをgit管理せずXcodeGenで生成していますが、XcodeGenでも従来のフレームワークと同じようにXCFrameworkのリンクを記述できます(クックパッドアプリ開発におけるXcodeGenの活用については、こちらの記事をごらんください)。XcodeGenのymlで、carthageというキーでフレームワークを指定していた所を、 frameworkというキーで、Carthageのビルドディレクトリにあるxcframeworkファイルを指定するだけです。

Build PhaseのLink Binary With Librariesの欄で、このように従来のフレームワークと同じ様にxcframeworkが指定されています(XcodeGenを使わずにプロジェクトファイルを編集する方は、この様にすれば動くはずです😉)。

f:id:hiragram:20210309145211p:plain

lipo時代のワークアラウンドとして行われていた、arm64用バイナリの除外も必要ないので、M1プロセッサのMac上で動くシミュレータでもアプリを動かすことが出来ます。クックパッドアプリはすでにワークアラウンドを削除しXCFrameworkに移行したバージョンが毎週リリースされており、この移行による問題は今の所報告されていません(クックパッドアプリ開発における毎週の自動サブミットについては、こちらの記事をごらんください)。

ただし、XcodeやCarthageにおけるXCFrameworkのサポートがまだ成熟していないからか、キャッシュの有無の判定が正しくないことがあります。例えばCarthage側でライブラリを更新してバージョンが変わったとき、XcodeやCarthageのキャッシュを一度クリーンしないと新しいXCFrameworkを使ってくれないことが多いです。原因はまだ調べられていませんが、おおよそ以下のことをやると正しくアプリがビルドできるようになります。

  • Xcodeを再起動する
  • Xcodeで Clean Build Folder をする
  • Carthage/Build を削除して再ビルドする

まとめ

iOS版クックパッドアプリでは、Carthageで管理されていたライブラリをXCFrameworkとして扱うようになりました。また、その過程で、SwiftPMなどの別のアプローチについても検証するよい機会となりました。

ちなみに、Xcode12.5 Beta2から、Xcode上のSwiftPMで外部ライブラリをDynamic Frameworkとしてビルドできるようになりました。これによって、先述のRxTestの問題が解決されているかもしれません(まだ未検証です😄)。モバイル基盤部では今後もビルド環境をモダンに保つための取り組みを続けていきます。このような領域に興味がある方は、ぜひ以下のリンクからご応募ください!

info.cookpad.com

レシピサービスのフロントエンドに CSS in JS を採用した話

$
0
0

こんにちは。技術部クックパッドサービス基盤グループのkaorun343です。我々のチームでは レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話にて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回はこの新システムの CSS の話 です。

背景

クックパッドのレシピサービスを Next.js と TypeScript で置き換えはじめた当初、CSS については Next.js に標準で組み込まれているCSS in JS ライブラリである styled-jsxを使っていました。プロジェクトが大きくなりはじめたタイミングで 「CSS の技術選定を考えなおしてもいいかもしれない」とチームの中で話し合い、改めて技術選定をしました。

技術選定

結論として、本システムでは CSS in JS ライブラリのemotionを採用しました。emotion には css prop を使う @emotion/react(旧 @emotion/core)と、 styled-componentsライクな記法が使える @emotion/styledがありますが、css prop を使う @emotion/reactを採用しました。記法については React の style prop のような Object Stylesではなく、通常の CSS と同じように書くことができる、タグ付きテンプレートリテラルを用いた String Stylesを採用しました。

npmtrends を見ると、一番ダウンロード数が多いのは @emotion/react の昔の名前である @emotion/core となっています(技術選定時は @emotion/core だったので、こちらの名前で比較)。しかし @emotion/styled が依存するライブラリであるため、@emotion/react 単体で使うケースはそう多くありません。したがって @emotion/styled と styled-components が広く使われていることがわかります。

f:id:kaorun343:20210313093352p:plain
CSS in JS ライブラリのダウンロード数

まず、ダウンロード数が多くないにも関わらず @emotion/react による css prop を採用した理由を説明します。

1 点目はカプセル化ができるという点です。CSS はグローバルな名前空間であるため名前が衝突し、他の要素に意図せずスタイルを当ててしまう可能性があります。

<mainclass="main"><sectionclass="section"><h2class="title">タイトル</h2></section></main>
/* スタイルA */.main{.title{color: green;
  }}/* スタイルB */.section{.title{color: orange;
  }}

例えばこの例では、 スタイル A とスタイル B がどちらも .titleを指定しているため、 h2.titleの文字色は 2 つの記述する順番に依存します(この例の順番だとオレンジ色になります)。 BEM などの命名規則を採用すれば、名前の衝突を避けることができますが、スタイル名の記述が長くなってしまう別の問題があります。 一方で emotion はスタイルごとに一意な ID が割り振られるため、セレクタ名が競合することはありません。したがって上記のような問題を防ぐことができます。

2 点目は静的解析がしやすいという点です。CSS Modulesstyled-jsxでは、通常の CSS と同様にセレクタを記述して classNameにクラス名を書いていくことになります。これらの手法の問題点は、クラス名を間違っていたとしても実行するまで気づくことができないこと、未使用のスタイルを静的解析により検出することができないことです(前者については、通常は Web ブラウザに画面を表示しながら作業するためそれほど大きな問題ではありませんが)。一方 emotion は、ESLint や TypeScript コンパイラといった JavaScript 向けの静的解析ツールにより、未使用の CSS を簡単に検出できます。

3 点目はコードレビューのしやすさです。これは styled-components の記法との比較です。styled-components の記法では、スタイルが当てられた DOM 要素や、スタイルが上書きされた既存のコンポーネントがどれもパスカルケースのコンポーネントとなります。

// styled-components の記法exportconst StyledComponent: FC =()=>{return(<Wrapper><IconWrapper><BellIcon /></IconWrapper><MainText>お知らせ</MainText><RightText>10</RightText></Wrapper>);};// css propによる記法exportconst CssProp: FC =()=>{return(<div css={wrapperStyle}><div css={iconWrapperStyle}><BellIcon /></div><div css={mainTextStyle}>お知らせ</div><div css={rightTextStyle}>10</div></div>);};

この2つのコンポーネントを見ていただければわかると思いますが、パスカルケースの JSX タグを見たときに、それが「機能を持つコンポーネント」なのか、もしくは「スタイルが当てられたコンポーネント」なのか、ひと目で判断しにくいです。この理由から styled-components の記法は不採用となりました。

次に String Styles を採用した理由を説明します。

Object Styles では font-sizeなどのケバブケースの CSS プロパティを fontSizeのようにキャメルケースで記述しなければなりません。一方で String Styles は通常の CSS と同じように記述できます。通常の CSS の記述に慣れ親しんでいるメンバーが多いため、読みやすさや書きやすさの点から String Styles を採用しました。

const objectStyle = css({
  fontSize: "30px",
  color: "blue",});const stringStyle = css`  font-size: 30px;  color: blue;`;

emotion の機能

emotion は stylisという CSS ライブラリが基盤となっており、以下の機能を提供してくれます。

  • ベンダープレフィックスの自動付与
  • セレクタのネスト
  • メディアクエリのサポート
  • テーマ機能のサポート

CSS を返す関数を定義することで、動的に スタイルを生成できます。

const getTextareaStyle =(isValid: boolean)=> css`  border: solid 1px ${isValid ? "green" : "red"};`;

また別の CSS オブジェクトを元に新しいスタイルを定義できます。

const buttonBaseStyle = css`  width: 100%;  color: red;`;/** * const buttonSpecialStyle = css` *   width: 100%; *   color: red; *   font-size: 16px; * `; */const buttonSpecialStyle = css`  ${buttonBaseStyle}  font-size: 16px;`;

それ以外にも @emotion/babel-pluginにより Babel のトランスパイル時にコードの最適化をおこない、不要な改行やインデントの削除などをおこなうことができます。

styled-jsx から emotion への置き換え

この 2 つのライブラリは共存させることが可能だったため少しずつ emotion へ置き換えることができました。移行の過程で CSS の記述を大きく変える必要がなく、プロジェクトが始まって間もない頃で変更箇所が少なかったことから、数回のプルリクエストで移行を完了しました。

emotion を用いた React コンポーネントの書き方

こちらがコンポーネントのコード例です。

import{ css }from"@emotion/react";import{ FC, useEffect }from"react";import{ StepListItem }from"./StepListItem";import{ Step }from"./Step";type Props ={
  steps: readonly Step[];};exportconst StepList: FC<Props>=({ steps })=>{
  useEffect(()=>{//},[]);return(<section css={rootStyle}><h2 css={sectionTitleStyle}>手順</h2>{stpes.map((step)=>(<StepListItem key={step.id} step={step} />))}</section>);};const rootStyle = css`  padding: 8px;`;const sectionTitleStyle = css`  font-size: 16px;&:hover {    text-decoratoion: underline;  }`;

CSS in JS では基本的に 1 つのファイルにコンポーネントの HTML、CSS、JavaScript をすべて詰め込みますが、それらを記述する順番が読みやすさに強く影響します。そこで本システムでは CSS をファイルの一番下に配置しました。

CSS は JavaScript よりも HTML (JSX) と密接に関連しています。また React の関数コンポーネントは関数の前半に React フックを書き、後半に JSX タグを書く構成がほとんどです。もし CSS の定義が関数コンポーネントの前に置かれていると、 React フックの行数が増えるほど CSS と JSX が離れてしまい、コードが見にくくなってしまいます。その点今回の配置では、CSS の変数の使用箇所が定義よりも前にくる違和感はありますが、 CSS と JSX が近い位置に配置されてコードが読みやすくなりました。 Vue.js のスタイルガイドにおいても <style></style>を一番下に配置するように推奨しています。

stylelint の導入

CSS の宣言そのものに対する静的解析ツールの stylelintもあわせて導入しました。存在しないプロパティや誤った値の指定を検出してくれます。stylelint 単体では emotion のコードを検証してくれないため、emotion の CSS 宣言をチェックするために、 stylelint-processor-styled-componentsを利用しています。こちらは名前に styled-components とある通り、もとより styled-components 向けのライブラリです。しかしemotion に対しても問題なく使えます。

Emotion には Next.js の SSR 環境 に起因する問題がありました。これは SSR 環境において:nth-childなどの *-childが使えないという問題です( https://github.com/emotion-js/emotion/issues/1178)。SSR 環境では <style></style>タグが途中に挿入される場合があり、正しくスタイルを当てられない可能性があるためです。

そこで、 *-childの代わりに *-of-typeを使うように stylelint にプラグインを追加することで対処しました。

const stylelint = require("stylelint");

const ruleName = "local/unsafe-pseudo-classes";
const messages = stylelint.utils.ruleMessages(ruleName, {
  expected: (unsafePseudoClass) => {return `The pseudo class"${unsafePseudoClass}" is potentially unsafe when doing server-side rendering. Try changing it to "${unsafePseudoClass.replace("-child",
      "-of-type"
    )}".`;},
});

module.exports = stylelint.createPlugin(ruleName, function (primaryOption) {returnfunction (postcssRoot, postcssResult) {const validOptions = stylelint.utils.validateOptions(
      postcssResult,
      ruleName,
      {
        actual: primaryOption,
        possible: [true],
      }
    );

    if (!validOptions) {return;
    }

    postcssRoot.walkRules((rule) => {const unsafePseudoClass = /:(?:first|nth|nth-last)-child/g.exec(
        rule.selector
      )?.[0];

      if (unsafePseudoClass) {
        stylelint.utils.report({
          message: messages.expected(unsafePseudoClass),
          node: rule,
          result: postcssResult,
          ruleName,
        });
      }});
  };
});

module.exports.ruleName = ruleName;
module.exports.messages = messages;

テストについて

本システムでは JestReact Testing Libraryによるコンポーネントのテストをおこなっており、スナップショットテストも活用しています。

@emotion/babel-preset-css-propを利用することで、CSS の記述を展開してくれます。そのためスナップショットで CSS に変更がないか確認できます。以下にコンポーネントのスナップショットテストの例を示します。

/** @jest-environment jsdom *//** @jsx jsx */import{ css, jsx }from"@emotion/react";import{ render }from"@testing-library/react";import{ FC }from"react";const MyComponent: FC =()=>{return(<div><button type="button" css={buttonBaseStyle}>BaseStyle</button><button type="button" css={buttonSpecialStyle}>
        Special Style</button></div>);};const buttonBaseStyle = css`  width: 100%;  color: red;`;const buttonSpecialStyle = css`  ${buttonBaseStyle}  font-size: 16px;`;

it("renders component",()=>{const{ container }= render(<MyComponent />);
  expect(container.firstChild).toMatchInlineSnapshot(`    .emotion-0 {      width: 100%;      color: red;    }    .emotion-1 {      width: 100%;      color: red;      font-size: 16px;    }<div><button        class="emotion-0"        type="button">        Base Style</button><button        class="emotion-1"        type="button">        Special Style</button></div>  `);});

将来的には画像回帰テストも導入して、より堅牢なシステムの構築を検討しています。

さいごに

今回はレシピサービスの新システムにおける CSS の話を紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を進めていきます。この取り組みを一緒に進めてくれる仲間を募集していますので、興味のある方はぜひご気軽にご連絡ください。


GraphQL Code Generator で TypeScript の型を自動生成する

$
0
0

技術部の外村(@hokaccha)です。

レシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 - クックパッド開発者ブログ

という記事を書きましたが、この中で詳しく説明しなかった GraphQL のスキーマやクエリから TypeScript の型定義を自動生成する仕組みについて紹介します。

なお、今回紹介したコードは以下で試せます。

https://github.com/hokaccha/graphql-codegen-example-for-techlife

GraphQL Code Generator を使った型生成

GraphQL のスキーマから TypeScript の型を生成するためのライブラリはいくつかあります。

などが有名どころです。今回はシンプルさや拡張性を考えて GraphQL Code Generator を採用したので、GraphQL Code Generator を使ったコード生成について紹介します。

GraphQL Code Generator 自身は TypeScript 以外の言語に対応していたり、TypeScript の中でも様々な機能のプラグインが提供されており、その中から自分の用途にあったプラグインを組み合わせを選ぶことになります。

今回は自動生成で以下の目的を達成します。

  • クライアントサイドで発行するリクエストとレスポンスに TypeScript の型をつける
  • サーバーサイドの Resolver に TypeScript の型をつける

クライアントサイドとサーバーサイドで利用するプラグインや実装は分断されるのでそれぞれ分けて解説します。

クライアントサイド

まずはクライアントサイドです。使っているプラグインは以下です。

@graphql-codegen/typescriptは TypeScript の型生成する場合に必用なプラグインで TypeScript のコードを生成する場合に必用です。@graphql-codegen/typescript-operationsは GraphQL のクエリとスキーマを元に TypeScript の型を自動生成します。

設定ファイルは次のようになります。

# codegen.ymlschema: schema.graphql
documents: graphql/**/*.graphql
generates:lib/generated/client.ts:plugins:- typescript
      - typescript-operations

documentsには、リクエストするときに発行するクエリを記述したファイルを指定します。スキーマとクエリは次のようになっていたとします。

# schema.graphqltypeRecipe{id: Int!title: String!imageUrl: String}typeQuery{recipe(id: Int!): Recipe!}
# graphql/getRecipe.graphqlquerygetRecipe($id: Int!) {recipe(id: $id) {idtitleimageUrl}}

getRecipe.graphqlはアプリケーションから発行するクエリです。アプリケーションのコード内に書くのでなく、ファイルを分けて書いています。

これで graphql-codegenコマンドを実行すると以下のコードが生成されます。

exporttype Maybe<T>= T | null;exporttype Exact<T extends{[key: string]: unknown }>={[K in keyof T]: T[K]};exporttype MakeOptional<T, K extends keyof T>= Omit<T, K>& {[SubKey in K]?: Maybe<T[SubKey]>};exporttype MakeMaybe<T, K extends keyof T>= Omit<T, K>& {[SubKey in K]: Maybe<T[SubKey]>};/** All built-in and custom scalars, mapped to their actual values */exporttype Scalars ={
  ID: string;String: string;Boolean: boolean;
  Int: number;
  Float: number;};exporttype Recipe ={
  __typename?: 'Recipe';
  id: Scalars['Int'];
  title: Scalars['String'];
  imageUrl?: Maybe<Scalars['String']>;};exporttype Query ={
  __typename?: 'Query';
  recipe: Recipe;};exporttype QueryRecipeArgs ={
  id: Scalars['Int'];};exporttype GetRecipeQueryVariables = Exact<{
  id: Scalars['Int'];}>;exporttype GetRecipeQuery =({ __typename?: 'Query'}& { recipe: ({ __typename?: 'Recipe'}& Pick<Recipe,'id' | 'title' | 'imageUrl'>)});

このとき、スキーマとクエリに型の不整合があればコード生成のときにエラーになるので、スキーマに違反するようなクエリを書くことができません。例えばクエリを以下のようにしてみます。

querygetRecipe($id: Int!) {recipe(id: $id) {foo}}

これで graphql-codegenを実行するとこのようにエラーになります。

$ npx graphql-codegen
  ✔ Parse configuration
  ❯ Generate outputs
    ❯ Generate lib/generated/client.ts
      ✔ Load GraphQL schemas
      ✔ Load GraphQL documents
      ✖ Generate
        →         at ~/local/src/github.com/hokaccha/graphql-codegen-example-for-techlife/client/graphql/getRecipe.graphql:3:5


 Found 1 error

  ✖ lib/generated/client.ts
    AggregateError:
        GraphQLDocumentError: Cannot query field "foo" on type "Recipe".
    (snip)

これだけでもだいぶ便利ですね。

しかし、まだこれだけだと型が生成されただけでアプリケーション内でリクエストとレスポンスに型を与えることはできていません。それを実現するのが @graphql-codegen/typescript-graphql-requestです。これは graphql-requestというライブラリをベースにしたクライントを自動で生成してくれます。

他にも react-queryをベースにして React hooks として使える @graphql-codegen/typescript-react-queryなど、いくつか選択肢はありますが、今回は SSR でも同じ用に使えてできるだけシンプルなものという理由で @graphql-codegen/typescript-graphql-requestを選択しました。

設定ファイルは pluginstypescript-graphql-requestを足すだけです。

# codegen.ymlschema: schema.graphql
documents: graphql/**/*.graphql
generates:lib/generated/client.ts:plugins:- typescript
      - typescript-operations
      - typescript-graphql-request

これでもう一度 graphql-codegenを実行すると、先程の生成したファイルに追加して以下のようなコードが生成されます。

exportconst GetRecipeDocument = gql`    query getRecipe($id: Int!) {  recipe(id: $id) {    id    title    imageUrl  }}    `;exporttype SdkFunctionWrapper =<T>(action: ()=> Promise<T>)=> Promise<T>;const defaultWrapper: SdkFunctionWrapper = sdkFunction => sdkFunction();exportfunction getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper){return{
    getRecipe(variables: GetRecipeQueryVariables, requestHeaders?: Dom.RequestInit["headers"]): Promise<GetRecipeQuery>{return withWrapper(()=> client.request<GetRecipeQuery>(GetRecipeDocument, variables, requestHeaders));}};}exporttype Sdk = ReturnType<typeof getSdk>;

先程別ファイルにした getRecipeクエリもこの自動生成コードに含まれており、getRecipe()でこのクエリを使ってリクエストし、レスポンスにも型がつきます。アプリケーションからはこのように使います。

import{ GraphQLClient }from"graphql-request";import{ useEffect, useState }from"react";import{ getSdk, Recipe }from"./generated/client";const client =new GraphQLClient("http://localhost:8000/graphql");const sdk = getSdk(client);asyncfunction getRecipe(id: number){const response =await sdk.getRecipe({ id });// const response = await sdk.getRecipe(); // Error: Expected 1-2 arguments, but got 0.

  console.log(response.recipe.id);// id: number
  console.log(response.recipe.title);// title: string
  console.log(response.recipe.imageUrl);// imageUrl?: string// @ts-expect-error
  console.log(response.recipe.foo);// Property 'foo' does not existreturn response.recipe;}

このように、リクエストとレスポンスに対して自動生成された型がつきます。また、クエリの入力部分は query getRecipe($id: Int!)でしたが、この入力パラメータについても型がつきます。

これでクライアントにおけるリクエストとレスポンスに型がつきました。

サーバーサイド

次にサーバーサイドです。サーバーサイドでは以下のプラグインを使います。

@graphql-codegen/typescript-resolversが GraphQL サーバーの Resolver の型を自動生成するためのプラグインです。

設定ファイルは次のようにします。

# codegen.ymlschema: schema.graphql
generates:lib/generated/resolvers.ts:plugins:- typescript
      - typescript-resolvers

これで生成された型定義を使って Resolver を次のように書きます。

import{ Resolvers }from"./generated/resolvers";exportconst resolvers: Resolvers ={
  Query: {
    recipe: async(_parent, args, _context, _info)=>{return{
        id: args.id,
        title: "recipe title",
        imageUrl: null,};},},};

これだけです。argsに入力値が渡ってきますが、これにはスキーマで指定されている id: Int!の型が渡ってきます。もちろん返り値もチェックされているので返している値がスキーマと整合性が取れていないと型エラーになります。

この Resolver は、graphql-toolsmakeExecutableSchemaにそのまま渡せる型として定義されます。ですので、makeExecutableSchemaで作った schema をそのまま実行できる Apollo や graphql-express などで実行します。以下は graphql-express の例です。

import fs from"fs";import express from"express";import cors from"cors";import{ makeExecutableSchema }from"graphql-tools";import{ graphqlHTTP }from"express-graphql";import{ resolvers }from"./lib/resolvers";const typeDefs = fs.readFileSync("./schema.graphql",{ encoding: "utf8"});const schema = makeExecutableSchema({
  typeDefs,
  resolvers,});const app = express();

app.use(cors());
app.use("/graphql", graphqlHTTP({ schema }));

app.listen(8000,()=>{
  console.log("listen: http://localhost:8000");});

これでサーバーサイドにも型がつきました。

まとめ

GraphQL のスキーマやクエリから TypeScript の型定義やクライアントを自動生成する方法について紹介しました。実際にクックパッドのアプリケーションでもほぼ同じ仕組みで動いています。できるだけシンプルに寄せるため Apollo などは使っておらず必要最低限にしていますが、GraphQL や TypeScript の強力な型付けの恩恵を受けることができて非常に便利です。

また、現状では Apollo や React Query などは組み合わせて使っておらず、キャッシュや hooks 化などは必要に応じてやっていますが、GraphQL Code Generator はそのあたりのプラグインも豊富で変更したいとなったときにプラグインの追加で気軽に構成変更できるのも便利です。

まだまだこのあたりも環境整備も発展途上ですので我こそは最高の環境を作るぞという方、もしくは API 呼び出しに型がなくて疲れてしまい、このような環境で開発してみたくなった方はお気軽にお問い合わせください!

Cookpad Online Spring Internship 2021 と Hackarade を合同開催しました

$
0
0

ユーザー・決済基盤部の三吉(@sankichi92)です。 昨年よりエンジニアの立場から新卒採用を担当しています。

2月の記事で告知したスプリングインターンシップを 3/22〜26 の日程で開催しました。 また、同時に社内でも Hackrade Remote #2 を開催し、社員もインターン生と同じ課題に取り組みました。 この記事はその開催レポートです。

合同開催について

今回のインターンシップのコンセプトは「クックパッドの社内ハッカソン "Hackarade"を体験してみよう!」というものでした。 Hackarade は Hack + Parade を組み合わせた社内の造語で、ハッカソンではあるものの「競技」より「祭り」の側面が強い社内イベントです。 アイデアや成果を競い合うのではなく、社内エンジニアの技術力向上を目的としています。 過去の Hackarade については、"Hackarade"でブログ内を検索してみてください。

そして、せっかく Hackarade をやるのならインターン生だけではもったいない、ということで社内 Hackarade の合同開催が決まりました。 具体的には、インターンシップと同じ期間に社内でも Hackarade を開催し、成果発表をインターン生と合同で行うことにしました。 以下は、合同開催が決定した時のツイートです。

クックパッドでは昨年の春よりオンラインでインターンシップを開催していますが、オンラインでは社内の雰囲気が伝わりづらいという課題がありました。 合同開催には、実際の社内イベントと重ねて関わる社員を増やすことで、会社の様子を少しでもイメージしやすくする狙いもあります。 実際、インターン生がシングルチャンネルゲストとして参加する Slack チャンネルは、これまでのインターンシップと比べても非常に活発でした。 スクリーンショットのように、インターン生21人を含む83人から5日間で3,000件を超えるメッセージが投稿されました1f:id:sankichi92:20210401000058p:plain

講義動画について

Hackarade には毎回テーマがあり、社内の第一人者による講義を受けたのち開発に取り組む形式が基本です。 今回のインターンシップ & Hackarade のテーマは「Web フロントエンド」で、講師を外村(@hokaccha)が務めました。 また、オンラインのメリットを活かす取り組みとして、講義動画を事前に撮影し、参加者各自の好きなタイミングで視聴してもらう形を取りました。 わからなかったところは何度も見返したり、知っている内容は倍速で飛ばしたりできると、インターン生・社員ともに好評でした。

ここで、実際に使用した講義動画を公開します。

講義動画はトピックごとに以下の4つからなります。

  1. JavaScript
  2. TypeScript
  3. React
  4. Next.js

モダン Web フロントエンドに入門したい方や、上に挙げたようなトピックをおさらいしたい方など、ぜひご覧になってください。

インターンシップについて

インターンシップは、初日に講義動画の共有や課題の発表を行い、最終日5日目に成果発表してもらう、というスケジュールでした。 その間、質問は Slack で受け付け、講師・TA の誰か回答できる人が回答します。 テキストだけで難しい場合は、Zoom を使用することもありました。

課題は、Next.js と TypeScript を使ってレシピサイトを作る、というものでした。 基本課題と発展課題に分かれており、基本課題は講義動画を見ればだいたいできる内容です。 一方、発展課題は Web フロントエンドに慣れている人でも5日間やることがなくならないよう、かなりのボリュームになっています。 詳細は https://gist.github.com/hokaccha/7003c700f7d2ad276bfb458edd862abeをご覧ください。 データについて、今回はインターンシップ用に Web API を用意しました。

講師の想定を超えて、基本課題を2日目の朝6時に終える猛者や、すべての発展課題に取り組む猛者がいました。 同じ課題に取り組んでいても、成果発表では参加者ごとの個性がよく出ていて感心しました。

今回のインターンシップでの大きなチャレンジは、従来のような対面型でのインターンを単にオンラインにするのではなく、オンラインならではの効果が得られるような実施方法を模索することでした。 先ほど触れた講義動画はそのひとつです。 会場という制約がないので、必ず全員が参加しなければならない場面も減らすことができます。 5日間を通して、時間を合わせて集まるのは初日と最終日のみの計5時間ほどでした。 2〜4日目は、毎日1時間オフィスアワーとして講師・TAが Zoom に待機する時間を設けていましたが、そちらよりむしろテキストのコミュニケーション方が活発でした。 3日目には、Tech MTG という隔週で開催しているエンジニア全員参加のミーティングを見学する機会も設けましたが、これも任意参加です。 事前に2〜4日目の時間の使い方は自由とアナウンスしていたので、インターンシップ期間中に研究したり、卒業式に出席したり、引越ししたりする参加者がいました。 インターンシップの様子は、Twitter ハッシュタグ #cookpad_spring_internから覗くことができます。

Hackarade について

社内の Hackarade は、前回のリモート開催時同様、開催期間のうち「8時間まで開発に使ってよい」というルールにしました。 また、インターンシップと異なり、以下の2部門があります。

  • 規定部門: インターン生向け課題のレギューレーションに従って開発する
  • 自由部門: 講義内容の技術を使って自由に開発する

規定部門はインターン生が5日間かけて取り組むボリュームということもあり、自由部門のエントリーが多くなりました。 ここからはいくつか作品をピックアップして紹介します。

Ruby コミッターの @mametterからは「ブラウザの上で Ruby を動かす」という発表がありました。 Web フロントエンドがテーマのはずなのに、irb が(ブラウザで)動き始めるという、予想の斜め上をいく作品でした。

また、おまけとして1時間(!)で書いた Next.js を使った Quine の紹介もありました。 遠藤さんの Quine は社内で有名ですが、インターン生にはどう見えたのか気になるところです。

買物プロダクト開発部の @solt9029からは「ズルできるババ抜き」の発表がありました。 発表中は「これを作ろうと思った思考プロセスが気になる」といったコメントが寄せられました。

他にも、分析 SQL のシェアができるアプリや、GHE・Slack などから社内情報をまとめて検索できるアプリなど、社員待望の便利ツールや、業務での利用を視野に入れた QR コード読み取り機能のプロトタイプ、趣味でやっているポッドキャストの Web ページなどなど、多彩な作品が集まりました。

また、規定部門の発表では、講師・TA から Auth0を使った認証やインクリメンタルサーチ、Storybookを使ったコンポーネント開発、Recoilを使った状態管理など、課題では扱われなかった少し発展的な技術の紹介がありました。


以上が、Cookpad Online Spring Internship 2021 & Hackarade Remote #2 の開催報告です。 ご参加いただいた皆さま、本当にありがとうございました!

スプリングインターンシップは終わってしまいましたが、クックパッドでは就業型インターンシップを通年で募集しています。

また、サマーインターンシップも例年どおり開催予定です。 興味のある方はぜひご応募ください!


  1. アプリやインテグレーションによる投稿はなく、すべて人間による投稿です。Tech MTG や成果発表では多くの社員を巻き込んで盛り上がりました。

JSON Schema をクックパッドマートの商品登録画面に導入した話

$
0
0

主にバックエンドのエンジニアとしてクックパッドマートの開発に携わっている塩出( @solt9029)です。

美味しい食材をユーザにお届けするサービスであるクックパッドマートでは、日々街の販売店や地域の生産者が商品の登録を行っています。
商品を登録する際、販売者は消費期限をはじめとする様々な品質保証の情報を正確に入力する必要があります。
しかし、商品の種類や状態に応じて記載するべき品質保証の情報は異なるため、全項目が羅列されるフォームでは正確な入力が困難であり、販売者および商品の審査を行う社内の運用メンバに対して大きな負担をかけていました。 そこで、 JSON Schema を利用して複雑なフォームの出し分けを自動で制御し、またバックエンド側でのバリデーションも行うことが出来る仕組みを導入しました。
その結果、商品の種類や状態を選択するだけで、適切な品質保証の情報が自動的に入力され、必要な項目のフォームのみが表示されるようになり、販売者および商品の審査を行う社内の運用メンバの負担を大きく減らすことが出来ました。

f:id:solt9029:20210405174433g:plain:w250
JSON Schema を導入した商品登録画面

f:id:solt9029:20210406100722p:plain:w460
JSON Schema による商品の種類ごとのフォームの比較

背景・目的

クックパッドマートは、弊社が力を入れて取り組んでいる新規事業の1つです。生鮮食品を中心として扱っているECプラットフォームで、街の販売店や地域の生産者が、販売者としてクックパッドマートに参加しています。コンビニエンスストア・ドラッグストア・駅・マンションなどの様々な場所に、ユーザの受け取り場所として専用の冷蔵庫が設置されています。ユーザはアプリから注文を行い、冷蔵庫から生鮮食品を受け取ることができます。

クックパッドマートでは、販売者が商品の登録や日々の出荷作業などを行うための機能を提供する販売者向け管理画面を開発しています。
販売者向け管理画面を通じて商品登録をする際に、商品名や写真、価格だけでなく、消費期限や解凍品かどうかなどの、品質保証や食品表示に関わる情報も入力する必要があります。
一方で、それらの情報は商品の種類や状態に応じて入力するべきものが異なり、全項目が羅列されるフォームでは正しい項目を入力することが難しい状態でした。

例えば、じゃがいもをそのまま販売する場合、消費期限を入力する必要はなく、「お早めにお召し上がりください」といった文言を特記事項として記載する必要があります。
また、製品として販売されているドレッシングのように、商品自体に消費期限や賞味期限が元から記載されている場合、消費期限ではなく保証消費期限として、出荷日から最低限品質が保証される日数を入力する必要があります。この場合、販売者は保証消費期限よりも長い日数の消費期限や賞味期限が記載された商品を出荷する必要があります。
その他には、鮮魚や魚介加工品などの商品を販売するときには、生食用なのか・養殖なのか・解凍品なのか、といった項目を明示する必要があります。
このように、商品登録をする際には、商品の種類や状態に応じてそれぞれ異なった種類のデータを入力する必要がありますが、全項目が羅列されるフォームから人手でどの情報を入力するべきかを都度判断するのはとても困難です。そのため、商品の種類や状態に応じて、適切なフォームが出し分けされる仕組みが求められていました。

また、フロントエンド側でフォームの出し分け制御がされるだけでは、不正なデータの登録を完全に防ぐことはできません。社内の運用メンバが商品の販売開始前に商品審査を行っているものの、商品審査の負担やミスを避けるために、バックエンド側でバリデーションされた上で商品が登録されている状態が望ましいです。

複雑なフォームの出し分けのみであれば、 JavaScript でオレオレ実装をすることも考えましたが、バックエンド側のバリデーションまで考慮すると、共通した Schema が存在している状態が望ましいと考えました。そこで、複雑なフォームの出し分けおよびバリデーションをすることが可能な仕組みとして、 JSON Schema を導入することにしました。

実装

JSON Schema とは

JSON Schema とは、その名の通り JSON の構造を定義したものです。 OpenAPI で利用されている記述方法として知っている方も多いかもしれません。百聞は一見にしかずということで、JSON Schema とそれに対応する JSON の簡単なサンプルをご紹介します。

{
  title: "お料理レシピ",
  type: "object",
  properties: {
    id: { title: "ID", type: "integer" },
    title: { title: "タイトル", type: "string" },
    content: { title: "作り方", type: "string" },
    public: { title: "公開中", type: "boolean" }
  },
  required: ["id", "title", "content", "public"]
}
{
  id: 100,
  title: "カルボナーラの作り方",
  content: "ベーコンと玉ねぎを食べやすい大きさに切ります。〜(以下略)",
  public: true
}

このように型や必須項目など、 JSON の構造を定義することができます。他にも、本記事で扱う dependencies や oneOf などといった、複雑な構造を定義するときに便利な方法が豊富に用意されています。より詳細な仕様については、 Understanding JSON Schemaをご参照ください。

JSON Schema の定義

商品登録時の JSON Schema を定義するにあたって、商品の種類ごとに必要なフォームの出し分けができるような構造を考える必要がありました。詳細な説明は省きますが、代表的な商品の種類を一部抜粋してご紹介します。

  • 根菜類(玉ねぎ、人参、じゃがいも)
    • 品質保証の種類:品質保証に関する特記事項 → テキストを入力するフォームが必要(デフォルトでは「お早めにお召し上がりください。」と入力される)
  • 鶏肉
    • 品質保証の種類:消費期限 → 日数を入力するフォームが必要
    • 解凍表示を入力するフォームが必要
  • 魚介加工品
    • 生食表示(生食用 / 加熱用)を入力するフォームが必要
    • 養殖表示(養殖 / 天然)を入力するフォームが必要
    • 解凍表示を入力するフォームが必要
    • 解凍品を選択した場合
      • 品質保証の種類:消費期限 → 日数を入力するフォームが必要
    • 非解凍品を選択した場合
      • 品質保証の種類:保証消費期限(配送日から最低限品質が保証される期間) → 日数を入力するフォームが必要

特に魚介加工品が一番複雑に見えると思います。このように、ある特定の値に応じてフォームの出し分けをする必要がある場合には、 JSON Schema の definitions・dependencies・oneOf などを利用します。魚介加工品の要件を JSON Schema として表現したときに、最終的には下記のようになりました。それなりに複雑な JSON にはなりますが、自前で出し分けを自動で制御するロジックやバリデーションをゼロから実装するよりも、遥かに簡単に記述することができました。

{
  required: ["raw", "thawed", "farmed"],
  properties: {
    category_id: { const: "魚介加工品カテゴリのID" },
    thawed: { "$ref" => "#/definitions/thawed" },
    farmed: { "$ref" => "#/definitions/farmed" },
    raw: { "$ref" => "#/definitions/raw" },
  },
  dependencies: {
    thawed: {
      oneOf: [
        {
          properties: {
            thawed: { const: true }, # 解凍品だった場合、消費期限quality_guarantee: { "$ref" => "#/definitions/quality_guarantee/definitions/expiration" },
          },
        },
        {
          properties: {
            thawed: { const: false }, # 非解凍品だった場合、保証消費期限quality_guarantee: { "$ref" => "#/definitions/quality_guarantee/definitions/guarantee_expiration" },
          },
        },
      ],
    },
  },
}

ライブラリ選定

クックパッドマートの販売者向け管理画面について、フロントエンドは Rails の View の仕組みを用いて HTML が返される仕組みとなっています。また、動的な処理などを追加する際には TypeScript / React を用いている状態です。そのため、 React で JSON Schema に基づいたフォームの出し分けを自動で制御するライブラリとして、 react-jsonschema-formを利用することとしました。
また、バックエンドについては Rails で開発が行われているため、 Ruby 製で JSON Schema に基づくバリデーションをすることができるライブラリが必要でした。そのため、 json_schemerを選定することにしました。

json_schemer

json_schemer はとても簡単に導入することができました。下記のように検証したい JSON を渡してあげることでバリデーションをすることができます。

JSONSchemer.schema(json_schema).valid?(json_to_be_validated)

react-jsonschema-form

react-jsonschema-form が JSON Schema の定義に沿ってフォームの出し分けを自動で制御してくれるため、自分で実装する必要のある箇所は主に見た目に関する部分でした。具体的には uiSchema と、 Widget や FieldTemplate と呼ばれる React Component です。

FieldTemplate や Widget は JSON Schema のそれぞれの入力フォームを描画する際に利用される Component です。JSON Schema および後述する uiSchema で渡される値を Props として受け取り、その情報を元に描画を行います。実装例は下記の通りです。

exportconst FieldTemplate =(props: FieldTemplateProps)=>{const{ label, required, children, rawDescription, rawHelp }= props;return(<Card><Card.Header><div>{required ? (<Badge variant="primary">必須</Badge>) : (<Badge variant="secondary">任意</Badge>)}{label}</div></Card.Header><Card.Body>{rawDescription &&<Card.Text>{rawDescription}</Card.Text>}{children}{/* この部分で Widget の描画が行われる */}{rawHelp &&<small>{rawHelp}</small>}</Card.Body></Card>);};
exportconst RadioWidget =(props)=>(<div className="field-radio-group">{props.options.enumOptions.map((option, i)=>{return(<div key={i}><label><input
              disabled={props.disabled}type="radio"
              name={props.options.name}
              value={option.value}
              onChange={()=>{
                props.onChange(option.value);}}
            /><span>{option.label}</span></label></div>);})}</div>);

uiSchema について、下記は養殖か天然かを入力するフォームの定義例です。その入力フォームを描画するときに使用したい Widget の指定や、説明文の付与などの指定を行うことができます。uiSchema で指定された値は Widget の Props として渡されます。

{
  farmed: {'ui:disabled': isDisabled,
    'ui:name': 'item[farmed]',
    'ui:widget': RadioWidget,
    'ui:help': '養殖か天然を必ず選択してください。',
  },
  // ...}

JSON Schema を導入した結果

これまでは新規登録された商品の内、約10%の割合で品質保証の項目の入力不備がありましたが、JSON Schema を導入したことによって、商品の種類や状態を選ぶだけで品質保証の種類が自動的に選択されるようになったため、品質保証の項目に関する入力不備はゼロになりました。商品登録時の正確性や体験を改善し、商品審査の運用負担を大きく減らすことができました。
Schema に基づいた実装を行っているため、今後新しく要件が増えたとしても JSON Schema の定義を更新するのみで解決し、フロントエンド側のフォームの出し分け制御ロジック・バックエンド側のバリデーションを容易に追加・更新することが可能な状態になりました。

最後に

クックパッドマートでは事業成長のためにスピードを高めて開発に取り組んでおり、様々な技術に触れる機会も多くとても楽しい環境です。弊社では絶賛エンジニア募集中なので、興味を持って頂けた方はぜひ採用情報をご覧ください。

cookpad-mart-careers.studio.site

info.cookpad.com

クックパッドマートの生鮮食品を SORACOM の IoT デバイスで遠隔温度監視している話

$
0
0

クックパッドマートでサーバーサイドなどのソフトウェアエンジニアをしている石川です。

この記事では、クックパッドマートの物流の一部で SORACOM のサービスを活用して生鮮食品の遠隔温度監視を行っている話について、主にサーバーサイドの取り組みを紹介します。

クックパッドマートの物流

クックパッドマートの物流は、非常に大雑把に言うと以下のような手順になっています。

  1. 販売者さんが集荷場所に置いてあるコンテナへ商品を届ける。
  2. ドライバーさんが集荷場所からコンテナを集めて回る。
  3. 集めたコンテナを、商品の受け取り場所の冷蔵庫に届ける。
  4. 冷蔵庫に届いた商品をユーザーさんが受け取る。

まとめると、(販売者さん)→集荷場所→(ドライバーさん)→ステーション→(ユーザーさん)、という図式になっています。

ここでコンテナと言っているのは物理的な容器の方のコンテナです。以下のようなコンテナに食品を入れて運んでいます。

緑色の折り畳めるコンテナの写真です。
コンテナ

今回注目するのは、ドライバーさんが集荷場所から受け取り場所に届けるまでの間です。集荷場所から受け取り場所の間ではドライバーさんが車両でコンテナを移動させています。お肉やお魚などの生鮮食品を扱っているため移動の間も温度を低く保ちたいのですが、コンテナをそのまま運んでしまうと温度を低く保つのが難しいため、蓄冷剤の入った保冷用のボックスにコンテナを入れて運んでいます。

保冷用のボックスというのは次の写真のような、銀色の箱です。コンテナがいくつか入る程度の大きさになっています。

外側が銀色になっている、直方体の箱です。
保冷用ボックス

この保冷用ボックスの温度を遠隔監視しよう、というのがこの記事のテーマです。

GPS マルチユニット SORACOM Edition

2021 年 4 月現在、私たちはこの保冷ボックスの温度を「GPS マルチユニット SORACOM Edition」を使って計測しています。このセンサーは京セラさんのセンサー「GPS マルチユニット」をベースに SORACOM プラットフォームで利用しやすくするためのカスタムがなされているセンサーです。温度の他、湿度、加速度、位置情報を計測できます。

GPS マルチユニット SORACOM Edition の見た目は下のような感じです。後ろでコンテナの中に入っているものは食品サンプルです。

白くて薄い直方体の形をしたセンサーです。背景には保冷用ボックスが写り込んでいます。
GPS マルチユニット SORACOM エディション

GPS マルチユニット SORACOM Edition は、実装当初候補に挙がっていた他のセンサーと比較しても実装工数が少なく済みそうなことや、今回のユースケースに充分な測定精度があること、充分な数を入手できそうなことなどを理由に採用しました。

SORACOM プラットフォーム上でこの GPS マルチユニットを使う場合、SORACOM Lagoonを使ってダッシュボードを作れる他、SORACOM FunnelSORACOM Funkを使って他のクラウドサービスへデータを送ることができます。今回は SORACOM Funnel を使って AWS へデータを送っています。

温度データの利用

具体的な構成の説明に入る前に、私たちが温度データを使ってどのような機能を作ったかを説明します。

保冷用バッグの中には生鮮食品が入っているため、GPS マルチユニットの示す温度は低く保たれている必要があります。もし GPS マルチユニットの温度が高くなり始めた場合、その GPS マルチユニットの入った保冷用ボックスを運んでいるドライバーさんにご確認いただこうということになりました。

しかし GPS マルチユニット自体には温度を表示する液晶画面の類や異常を知らせるブザーなどはついていないため、何かしら別の方法でドライバーさんに異常を伝えなければいけません。そこで GPS マルチユニットの温度データを元にドライバーさんへ通知をする機能を作りました。

元々ドライバーさんには、当日の配送ルートの表示や集めるコンテナの指示などを行う目的でモバイルアプリを使っていただいていました。これはドライバーさん専用アプリで、販売者さんの商品を販売するアプリとは別のものです。社内では通称「ドライバーアプリ」と呼ばれています。

もし GPS マルチユニットの温度が高くなり始めた場合、このドライバーアプリ越しにプッシュ通知してお知らせするようにしています。以下のような感じです。

プッシュ通知のスクリーンショットです。内容には「シッパー内の温度が高くなっていませんか?」「タップして続ける」と書かれています。
プッシュ通知

※「シッパー」と書かれているのが保冷用ボックスのことです。

また、先述のように GPS マルチユニット自体を目で見ても温度は分からないため、ドライバーアプリから現在の温度が確認できるようにもしています。

ところでこの機能を実装するためには、それぞれの GPS マルチユニットをどのドライバーさんが持っているのか知らなければいけません。この紐付け登録もドライバーアプリを使って行っています。具体的には、それぞれの GPS マルチユニットに二次元コードを貼り付け、ドライバーさんにモバイルアプリで読み取ってもらうことで登録しています。

更にドライバーさん向けだけでなく、弊社の社内オペレーター向けにも温度データが見えるようにもしています。具体的には、Grafana を使ったダッシュボードで温度の時系列変化が確認できるようにしたり、GPS マルチユニットの温度が高い状態を維持した場合に Slack へ通知が来るようにしたりもしています。

構成

以上のような機能を作るため、サーバーサイドでは以下のような構成を採用しました。

各々のサービスを示すアイコンが矢印で結ばれています。まず GPS マルチユニット SORACOM Edition から SORACOM Funnel に矢印が伸びており、SORACOM Funnel から Amazon Kinesis Data Firehose に伸びています。そこから AWS Lambda に伸びており、AWS Lambda からは 2 本の矢印がそれぞれ Amazon CloudWatch と Amazon DynamoDB に伸びています。
構成図

まず、GPS マルチユニットから SORACOM へ送られてきたデータを SORACOM FunnelAmazon Kinesis Data Firehoseに転送します。続いてそのデータを AWS Lambdaを使って整形しつつ Amazon CloudWatchAmazon DynamoDBに流します。図からは省略していますがモバイルアプリからは API アプリを介して DynamoDB にある温度データにアクセスできるようになっています。なお、データの行き先が CloudWatch と DynamoDB の 2 つになっているのは開発事情の都合です。

構成自体はシンプルで、全体の実装にもそこまで長い時間はかかりませんでした。たとえば AWS Lambda の部分は AWS CDKを使って 1 日かからずに作れています。

なお、自分が不慣れだったこともあり、上記の構成に落ち着くまでには少し時間がかかりました。たとえば論点をひとつ取り上げると、SORACOM Funnel と SORACOM Funk のどちらを使うかというものがありました。

SORACOM Funnel と SORACOM Funk はどちらもクラウド間を結ぶ「アダプタ」であるという意味で似たサービスです。結果的に Lambda を実行するのであれば、SORACOM から別のクラウドへデータを運ぶ仕組みである SORACOM Funnel ではなく、SORACOM から別のクラウドの関数を実行する仕組みである SORACOM Funk を利用する選択肢もありました。しかし今回は SORACOM Funnel を採用しています。

これは、SORACOM Funnel の方がそれぞれの温度データのタイムスタンプをより正確に得られそうであったためです。GPS マルチユニット SORACOM Edition はデータ取得時の時刻を送らないため何かしらの時刻を使ってタイムスタンプを計算する必要があるのですが、SORACOM Funk を使った場合タイムスタンプの誤差が大きくなりうるという問題がありました。

というのも、今回の実装時点の SORACOM Funk は、GPS マルチユニットからのデータを受信した瞬間のタイムスタンプを保持していませんでした。このため SORACOM Funk を使った場合に温度データのタイムスタンプを得るには「SORACOM Funk 経由で実行された Lambda の内部で現在時刻を取得して温度データのタイムスタンプとする」ような実装が必要でした。しかし Lambda の実行が失敗してリトライされる可能性があることを考えると誤差が許容できないと判断しました。SORACOM Funnel ではデータを受信した瞬間のタイムスタンプが保持されており、この問題が起こりませんでした。

ただし SORACOM Funnel にも、GPS マルチユニットがデータを取得するタイミングと Funnel がデータを受信するタイミングにズレがあるという別の問題はあります。

この問題については、今回は温度について秒単位でのリアルタイム性は要求されておらず、またデータ取得時刻とデータ受信時刻のズレは充分小さいらしいことが分かったため、許容することにしています。実際私たちが気にしているのは「温度の低い状態が維持されているか」であり、特定時刻の温度を正確に知りたい訳ではないため、これで充分と判断しました。

このようにいくつかの論点を考えた上で今回の構成に至りました。そして実際今のところサービスが成長しドライバーさんが増えていく中でもこの構成のまま運用を進められており、ベターな選択であったと考えています。

最後に

以上のように、この記事では、GPS マルチユニット SORACOM Edition を使って生鮮食品の温度を遠隔監視している話を紹介しました。

ところで、実際にこの仕組みを使って温度を計測してみると、たとえば車がトンネルに入ると少しのあいだ温度が上手く取得できないことがあるなどの問題が分かっています。現状は安全側に倒してデータ取得失敗時も通知を行い、人力で判断している状態です。このような状況をどうしていくか、ハードウェアとソフトウェアの両面から改善を進めています。

このようにクックパッドマートでは実世界にまつわる様々な課題にハードからもソフトからも切り込み、スピード感のある開発を続けています。弊社ではただいま絶賛エンジニア募集中ですので、ぜひ採用情報をご覧くださいませ。

cookpad-mart-careers.studio.site

info.cookpad.com

※SORACOM は、株式会社ソラコムの登録商標または商標です。

KomercoとFirebaseの話【前編】 - Firestoreの設計パターン

$
0
0

こんにちは。Komercoの高橋です。

Komercoがリリースされてからもうすぐ3年が経とうとしています。 クックパッドの新規事業「Komerco」ではバックエンドのほぼ全てをFirebaseで運用してします。 新規事業のためエンジニアの数はまだ少ないものの、Firebaseのおかげでエンジニアはサービス開発に専念できている状況で、昨年はWeb版のリリースや送料無料イベントもあり、ユーザ数がさらに増加してきています。

これまで多くの機能を開発してきましたが、その中でFirebaseをより有効に使えるよう試行錯誤してきました。 これからFirebaseを使おうか考えている人にも、今現在Firebaseを使っている人にも参考になるよう、Komercoで得た知見を書いていこうと思います。

前後編となっており、前半はFirestoreの設計についてお話しようと思います。

当たり前の機能だけどFirestoreだと実現しにくいもの

Komercoは器や料理道具、食材や調味料を扱うECサービスです。 そのためFirestoreには「ユーザ」「商品」「ショップ」などのコレクションが存在し、主にこれらを操作しながら買い物の機能を実現しています。 また、ユーザとショップがやり取りを行うメッセージ機能や、Komercoからのお知らせ機能など、サービスとして標準的な機能もFirestoreで実現しています。

開発を続ける中で気づいたのは、一般的なサービスでよく見る機能でも、Firestoreで実現すにはひと工夫必要なことがあることです。 こういった問題にぶつかる度に、開発メンバーでどんな設計ができそうかアイデアを出し合ってきました。

ここでは、どういった機能がFirestoreで実現しにくいのか、それに対してどんな設計パターンがあるのか、それらのメリット・デメリットは何かを書いていこうと思います。

リスト表示に複数のコレクションの情報が必要

最初はよく語られる内容ですが、クライアントサイドジョインの問題です。 あるコレクションのリストを表示したいときに、各リストアイテムごとに別コレクションのデータが必要になるケースです。

例えばKomercoでは商品のリストを表示する画面で、各セルにショップの情報も表示しています。

f:id:yosuke403:20210420151618j:plain

Firestoreでは単一のクエリで単一のコレクションしか取得できない制約上、次のいずれかの手段を取ることになります。

冗長化: 取得するコレクションに別コレクションの情報を含めるようにする。

f:id:yosuke403:20210420151722p:plain:w700

クライアントサイドジョイン: クライアント側でコレクションを別々に取得して結合する。

f:id:yosuke403:20210420151700p:plain:w380

ちなみに、最初は単一コレクションの取得で済むよう設計できていても、その後の拡張で別コレクションのデータが必要になることも多いと思います。 Komercoも最近のアップデートで、商品のセルにショップの情報も載せるようになりました。 冗長化するかクライアントサイドジョインにするかの選択は、サービスを続けている間に必ず経験することになるのではないでしょうか。

実態としてクライアントサイドジョインが多くなりがち

冗長化には次のメリットには、

  • リスト表示に必要なデータの取得が一回のアクセスで済む。
  • クライアント側の実装がシンプルになる。

などがあり、クライアントサイドジョインのメリットは、

  • Firestoreのストレージ消費が少ない。
  • 冗長化の仕組み(Cloud Functionsの実装等)が不要。

などが挙げられると思います。

Komercoはあまり冗長化の選択ができていません。というのも冗長化の仕組みの実装・メンテナンスコストが大きくなりがちだからです。 例えば先ほどの商品にショップの情報を載せる例では、冗長化を行う場合、商品のコレクションにショップの情報の一部をCloud Functionsで付与する訳ですが、

  • ショップ情報更新時にそのショップの商品すべてを更新するCloud Function
  • 商品情報の新規作成時にショップの情報を取得して付与するCloud Function
  • Firestoreセキュリティルールの確認・変更
  • Firestoreインデックスの確認・変更(大きいフィールドを除外するなど)
  • (リリース後の場合)すでにFirestoreに存在する商品に対してショップ情報を付与する作業

等々が必要になります。 うまく管理していかないと、冗長化のCloud Functionsが増え続け、どのドキュメントを更新すると何に影響が出るのか分からなる可能性があります。

クライアントサイドジョインの場合、確かにクライアント側の実装コストは増えますが、大抵の場合はセルが画面に表示される直前にデータ取得する実装をするだけです。 セルに画像を表示する際によく行う実装と一緒です。 そのため、基本的にクライアントサイドジョインの選択を行っている状況です。

冗長化がうまくいった例

Komercoで冗長化がうまくいった例としては、商品一覧画面の画像取得の高速化があり、以前ブログでも書きました。

techlife.cookpad.com

この例のように、

  • クライアントサイドジョインでは処理完了までの時間が遅い
  • 対象の処理速度はサービスにとって重要である

といった場合において、冗長化の仕組みの導入コストに見合うのではないかと思っています。

Cloud Functionsで取得するという選択肢について

Cloud Functionsでジョインしてから返すことも考えたことがありますが、個人的にはあまりよくない手法だと思っています。 理由はFirestoreのセキュリティルールを無視した取得が可能になってしまうからです。

セキュティルールによって誰がクライアントを実装しても想定外の更新はなされないという安心感があり、特に新しいメンバーがジョインしたときになどに有効です。

どうしてもCloud Functionsで実装したい場合はそのようなケースに気をつけつつ、Firestoreと同じリージョンで作成するのがよさそうです。

リスト表示にページャーをつける

f:id:yosuke403:20210420153231p:plain

Firestoreで特定のコレクションのデータを一覧表示していく場合、一番簡単なのはアプリ上で一番下までスクロールしたら続く内容を追加ロードする仕様です。 これはFirestoreのstartAfterなどのカーソル句を使ったクエリと相性がいいためです。

一方で困るのは特定のページを指定して、その範囲のリストを取得することです。 特にPCの場合、追加ロードよりもページャーでリストを見ていく方が一般的かと思います。 ページを指定するということはつまり、取得開始位置と取得個数をクエリに含められばよいのですが、取得開始位置の指定はクライアントライブラリにはありません。

KomercoのWeb版はPCでの利用も想定しており、実現方法についてよく議論しました。

ドキュメントに何番目かを表す番号を付与する

クライアント的に理想的な状態は、何番目かを示すフィールドがドキュメントに追加されることだと思います。 取得範囲を指定が簡単で、全部で何件あるかも最後のドキュメントを取得すれば分かります。

ただこれは一方で、ドキュメントの順序変更や削除が発生した場合に、影響するコレクションの番号を振り直しをCloud Functionsで行う必要があります。 頻繁に更新されるようなコレクションの場合は、書き込み数の上限などに注意が必要です。

f:id:yosuke403:20210420153434p:plain:h400

ページ指定以外の方法を考えてみる

例えば週に1回定期的に更新される記事コンテンツのようなものの場合、ページではなく年月を指定してその範囲の記事を取得する方法が考えられます。 これであればFirestoreのクエリが書けるので、実現は容易です。

f:id:yosuke403:20210420153455p:plain:h400

全件取得

先に挙げた戦略が取れない場合、データ量的に問題ないことを前提に、全件取得の判断をすることもあります。 これは最後の手段なので、仕様を調整するなど、できるだけ別の解は無いか考えるようにしています。

未読にバッジをつける

サービスからのお知らせやメッセージ機能を実装すると必要になるのが未読機能です。 未読の保存方法についてはいくつかパターンがあります。

未読管理対象のドキュメントに保存する

未読管理対象のコレクションに対して、

  • 閲覧するユーザ数が限定されている
  • 各ユーザの未読・既読状況が、他の閲覧可能ユーザに公開されてよい

のであれば、そのコレクション自体に未読・既読を表す情報を入れてしまうのがよいと思います。 未読の有無の確認もクエリひとつで確認できますし、クライアントサイドジョインも不要です。

Komercoのメッセージ機能を例に説明します。 この機能は、クリエイターとカスタマーの1対1チャットを行うものです。 チャットールムに当たるRoomコレクションがあり、そのサブコレクションとして各チャットメッセージに当たるMessageコレクションがあります。

例えばクリエイターがメッセージを送信すると、Messageコレクションにドキュメントが追加されます。 Cloud Functionはそのイベントを受けて、親のRoomのドキュメントを更新し、カスタマーの未読有を示す isCustomerReadフラグを falseにします。 カスタマーは isCustomerRead == falseで絞り込むことで未読メッセージを見つけられますし、Messageコレクションを取得しなくてもそのチャットルームに未読があることが分かります。

f:id:yosuke403:20210420154207p:plain

既読管理用のサブコレクションを作る

「サービスからのお知らせ」のように、全体向けの情報に対して未読管理したい場合、先の例のようにお知らせのドキュメントに全ユーザの未読情報を書き込むのは現実的ではありません。

そこで例えば、ユーザのサブコレクションに既読管理用コレクションを作成し、お知らせを読んだ場合はそのお知らせのIDを持つドキュメントを既読管理用コレクションに追加します。 お知らせ表示時はそのコレクションを比較し、既読管理用コレクションに存在しなければ未読と判断されます。

f:id:yosuke403:20210420154225p:plain

ただし、「1つでも既読のお知らせがあればバッジを表示したい」といったときに、お知らせも既読管理用コレクションも、最悪全件取得しないと分からないという問題があります。

最後に閲覧した日付より前の記事を未読とする

例えばお知らせで、お知らせごとに個別に未読管理する必要がない場合は、最後にお知らせ一覧を開いた日付を覚えておき、それ以前のお知らせを既読としてしまう方法があります。 この場合、未読の有無はその日付を使ってクエリすることができます。

f:id:yosuke403:20210420154315p:plain

ドキュメントごとの既読管理の重要性が低い場合は、こちらの手法がよいと思います。

まとめ

Komercoの経験をベースに、Firestoreを使った開発で多くの人が悩みそうなケースについて説明しました。 どの設計を選ぶかは結局仕様次第になるので、仕様をよく分析して設計を選んでみてください。

弊社もまだまだ試行錯誤中なので「自分はこうしています!」というアイデアをお持ちの方、ぜひお話聞かせてください。

info.cookpad.com

後編は、Firebaseに関する仕組化を行って、運用コストを下げている話をしようと思います。

Viewing all 802 articles
Browse latest View live