たごもりすメモ

コードとかその他の話とか。

SwiftUI PhotosPickerで選択した項目からJPEG/PNGを取り出す

SwiftUIで作るiOSアプリで、画像を選択し、その画像をどこかにアップロードしたい。アップロード先がHEICに対応してないのでJPEG/PNGあたりのフォーマットでやりたい。

これを考えたとき、iOS 16.0+ ならPhotosPickerが使える。が、実際に選択したあとでどうやってJPEG/PNGのデータを取り出すかにだいぶ悩んだのでメモっておく。他にもっとマトモな方法ないの? と思っている。

PhotosPickerを使う

これは既存のビューでボタンを押すとシートで下から出てくる感じにしたかったので、こんなコードをざっと書けばよい。これは1枚だけ選ばせたい場合で、複数枚選ばせたい場合は呼び出しのシグネチャが少し変わるのに注意。

@State var isPickingPhoto: Bool = false
@State var selectedPhotoItem: PhotosPickerItem? = nil

var body: some View {
    AnyView()
    .sheet(isPresented: $isPickingPhoto) {
        PhotosPicker(
            selection: $selectedPhotoItem,
            matching: .images,
            preferredItemEncoding: .current,
            label: { Text("Choose your profile image") }
        )
        .onChange(of: selectedPhotoItem) { photoItem in
            guard let photoItem else {
                return
            }
            uploadSelectedPhoto(photoItem)
        }
        .presentationDetents([.height(280)])
        .presentationDragIndicator(.visible)
    }

んで実際のデータ取り出しおよびアップロードはuploadSelectedPhoto()で行う。

PhotosPickerItemからデータを取り出す

実際にデータを取り出すとき、JPEGPNGのどちらかが欲しいとする。しかしiPhoneのカメラで撮影した画像はHEICがプライマリの画像形式になっている(ことが多い)ため、JPEGPNGが欲しい、と指定する必要がある。選択した写真が対応している形式はsupportedContentTypesで取得できる、が、これはUTTypeを返す。

photoItem.supportedContentTypes //=> [UTType.heic, UTType.jpeg]

ところで、PhotosPickerItemから実際に扱うデータの取り出しはloadTransferable(type:)関数で行うが、ここでtypeに指定するのはSwiftの型であってUTTypeでもMIME Typeでもない。

let transferable = try await photoItem.loadTransferable(type: Data.self)

えー、このDataの中身はなんなんだよ、対応しているUTTypeの形式で取り出させてくれよ、と思うが、そのようなAPIがなさそうに見える。本当に?

で、現状どうしているかというと、しょうがないからいちどUIImageを経由している。これ、画像変換が行われちゃってないかなあ、大丈夫? と思うものの、他に方法が見付かっていない。

// JPEGの場合
let data = UIImage(data: transferable)?.jpegData(compressionQuality: 1.0)
// PNGの場合
let data = UIImage(data: transferable)?.pngData()

このへんをまとめて、必要な場所で次のようなextensionを書いておいた。Swiftのfileprivate便利だよね。

typealias UploadPictureData = (data: Data, mimeType: String)

let PERMITTED_IMAGE_TYPES = [
    UTType.jpeg,
    UTType.png,
]

fileprivate extension PhotosPickerItem {
    func pictureData() async -> UploadPictureData? {
        var transferable: Data? = nil
        do {
            transferable = try await self.loadTransferable(type: Data.self)
        }
        catch {
            // logging
            return nil
        }
        guard let transferable else {
            // logging
            return nil
        }
        var data: Data? = nil
        var mimeType: String? = nil
        if self.supportedContentTypes.contains(UTType.jpeg) {
            data = UIImage(data: transferable)?.jpegData(compressionQuality: 1.0)
            mimeType = UTType.jpeg.preferredMIMEType
        } else if self.supportedContentTypes.contains(UTType.png) {
            data = UIImage(data: transferable)?.pngData()
            mimeType = UTType.png.preferredMIMEType
        } else {
            // logging
            return nil
        }
        guard let data, let mimeType else {
            // logging
            return nil
        }
        return (data, mimeType)
    }
}

もうちょっといいやりかた無いもんかなあ。

RubyKaigi 2023に行ってきた

2020年に開催できなかったのち、3年を経て松本市で行われたRubyKaigiに行ってきた。自分は2020年のときはスポンサー企業の一人としてやることがある予定だったのが、3年経って、なんでもないいち参加者として行くことになったなど、いろんな変化を感じた。それでもほぼフルに参加者が戻ってきたいつものRubyKaigiでもあって、なんかもう、すごくよかった。

RubyKaigiの成果

聞いてきた

Implementing "++" operator, stepping into parse.y

Implementing "++" operator, stepping into parse.y - RubyKaigi 2023

とにかく最高だったのはしおい(@coe401_)さんのやつ。これはやっている内容といい、プレゼンそのものの楽しさといい、本当に最高だった。

speakerdeck.com

スライドでもいいけど、動画が公開されたら見てない人にはぜひ見てほしい。自分ももう一度見たい。最高。「これを実装する」という目的に対して、とれそうな手段をRubyのいろんなレイヤであれこれ試してて、順を追ってRubyのすごく深いところまでどんどん潜っていっているし、あちこちで使ってる手段が本当に目的のためには手段を選んでいない感じで面白すぎる。ちゃんとオチまでつけていて、プレゼンとしての完成度もすばらしい。

自分がいちばん好きだったのはBinding#local_variable_setをメソッドのレシーバ自身を置き換えるために使っているところで、普通そんなことはできないんだけど一度パーサを経由してるならパーサがレシーバ自身が格納されている変数の名前を知っているじゃん、っていう、もう最高としか言いようのない使いかたをしていた。

このトークを聞いていた他の人々と話していると、けっこうみんな「自分が最高にウケたポイント」が違っていて*1、それがこのトークの幅の広さを表してもいると思う。好き。

The future vision of Ruby Parser

The future vision of Ruby Parser - RubyKaigi 2023

いつもお世話になっている金子(@spikeolaf)さんのやつ。

speakerdeck.com

金子さんのパーサにかける情熱がどこから湧いてくるのかいまいちわからないんだけど、結果として我々*2に利益があるいろんな改善が行われている上に、話を聞いているととにかく楽しそうにやっているのが本当に好き。LTもそうだけど、話を聞いているとついうっかりparse.yをさわりそうになってしまう魅力があってたまらないと思う。しおいさんのトークもそういう背景があって出てくるのかなと思える。こういうふうに色々な個人をひっぱっていける魅力のある話はほんと聞いてて楽しい。好き。

Ruby JIT Hacking Guide

https://rubykaigi.org/2023/presentations/k0kubun.html#day3

@k0kubunさんのやつ。楽しそうにぴょんぴょんしながら話してるのを見てると楽しくなる。

speakerdeck.com

とにかくruby-jit-challengeをやるぞ! という気分。これ作って出してくれてほんと嬉しい。RubyKaigi前にちょっとRJITさわってみようとしたんだけど、あまりに情報がなさすぎて挫折したんだよね。k0kubunさんも、もうとにかく速くしたいんだな、というのが聞いてると常に伝わってくるのがじつによい。あとJIT民主化というか、これも多くの人を巻き込む方向の技術なのがすばらしいと思う。好き。

Multiverse Ruby

Multiverse Ruby - RubyKaigi 2023

@shioyamaさんのトークで、これは自分がLFAを作ったときの問題意識と思いっきりかぶってたのが印象的で面白く聞いていた。

speakerdeck.com

話の内容も書いているコードもいいんだけど、特に同じ問題意識の人がいるのを発見したのが嬉しかった。偶然にも2日目の夜中にパブ(Old Rock)で本人と同席できて、名前空間どうにかしたい!!!!! っていう話でものすごく盛り上がった。これからしばらく自分がプライベートでやりたい活動に直結してて、もしかしたらこれがこのRubyKaigiで自分に一番大きい影響があったかもしれない。

Authorsrb & Fluentd実践入門販売

RubyKaigi本体では書店は出ないけど、最近本を出したRubyistで野良企画スタンプラリーをやろう、という #authorsrb に誘っていただいた。んで当日サイン会もやろうかという話になっていたので、だったら自分の本もあったほうがいいかなと思って急遽準備して会場でFluentd実践入門の手売りもやっていた*3

もう出てから半年以上経つし、そもそもRubyそのものの本でもない。ということで2〜3冊くらい売れるかなとか思ってたんだけど、今回松本に持っていったぶんは2日目の昼には全部売れてしまった。さらにまだ数人の方からないんですかと声をかけていただいたので、ちょっと持っていく数を誤ったかもしれない。でも、あまりに急遽準備したので、やれるだけはやった。会場だとこんなの買ってもらえるんだというのはすごい。あと実際にお金をいただいて売るという行為、だいぶ久しぶりにしたけど、これはなんつーか、いいですね。

その中でも、Fluentd実践入門が実際に役に立ったという話を聞かせてもらったりもして、本当にやってよかった。#authorsrb のスタンプラリーで声をかけていただくことも多かったし、他の著者陣の人達といっしょにやってる感もあって、楽しい&嬉しいイベントだった。

そのほかKaigi中のこと

RubyKaigi Day -1 のAsakusa.rbから始まってずーっと人としゃべってた感じ。Shopifyからの人達とも去年はあまりしゃべる機会が作れなかったけど、今年は話しておきたかった人達と話せてよかった。今回はやんちゃハウスに泊まったので、そこでもいろんな人と話ができた。モリスハウスを京都でやって以来で、毎回これでなくてもいいかなとは思うけど、やっぱこういうのも楽しいですね。モリスハウスまたやってみるのもいいなあ。

久々の人とも話せたし、でも人が多過ぎてあの人もいたはずなのに話せなかったなあというのもあるし。個人的な目的だったいくつかの会話はできたのでよかった。

自分のトークがないのはさびしい

しかし、本来はこのRubyKaigiに参加できない予定だったのを突然来ることになったのもあって、自分のトークは何もできなかった。去年のRubyKaigi 2022でもしなかったので、2回連続で壇上で何も話していない。これはなんというか、寂しいなあというか、悲しいなあというか。これに慣れちゃうと、なんか昔やってたらしいけど今はたんにカオが大きいだけ、みたいになっちゃうのかな。嫌だ……。

ということで、やることを決めたので書いたのが名前空間が欲しいの話。やるぞ!

*1:"+" "+"を"+="と"1"に置き換えるのもよかったですね……

*2:誰だろうね

*3:RubyKaigiの3日前!に「会場で売りたいんですけど……」という相談をもちかけるという、極めてひどい進行に @inao さんふくめ技術評論社の方々には本当にお世話になりました

ご意見募集: Rubyに名前空間サポート的なものが欲しいという話

LFAを書いたときの話にあるKernel#loadの第2引数で名前空間的なものを作れるんだけど、loadした先のファイルでrequireされてたらダメなんだよね、という話の続き。ダメなんだよねー、で終わってたんだけどRubyKaigi2023で@shioyamaさんのMultiverse Rubyを聞いて、ここに仲間がいた!!! ってなって、さらにそのあとバーで飲みながらやろうやろうって盛り上がったので、なんか色々考えている。

RubyKaigiの話は別途書くとして、いまはとりあえずこっち。

後半に、こんなものが欲しい、という話、および読んだ人の意見が欲しいということが書いてあるので、このあたりに何か思うところがある人はぜひ読んでみてください。どっちかというと、自分以外のRubyユーザがどう考えているのかを、bugsに出す前にまず知りたいなと思っています。

動機

Rubyにはみなさんご存知の通り、名前空間的なものがない。どんなスクリプト・ライブラリ・アプリケーションでも、クラス・モジュールを定義するとき、基本的には名前空間のトップレベルに置くか、あるいは既存のモジュール・クラスの下に置く。しかしこれには以下に説明するような問題がある。

名前の衝突

複数のライブラリが同じ名前を使っている場合、もちろん競合する。同じ種類(クラスorモジュール)だった場合はお互いを上書きするし、異なる種類だった場合にはふたつめを読み込んだ時点で例外となる。

またRubyの世界で支配的なRuby on Railsが「基本的にアプリケーションのクラス・モジュールはすべてトップレベルに置く」という規約を用いているのがこの制約を破滅的にしている。トップレベルの名前空間が膨大な数のユーザー定義クラスに消費されている「かもしれない」。このため、ライブラリ作者がライブラリを作るときには、トップレベルにはUserAccountStrategyGuildRecipeも使えない。*1

トップレベルで名前が衝突したとき、片方がアプリケーションであれば名前を変更できるかもしれないが、ふたつのライブラリが同じ名前を使っていた場合、ひとつのアプリケーションから両方を使うことは選択肢から無くなる。現状であれば、どちらかを諦めなくてはいけない。

複数バージョンの使用不可

ライブラリはバージョンアップを行っても、多くの場合、当然同じ名前を用いる。このため現在のRubyではどうがんばっても特定ライブラリの複数バージョンを1プロセス内には共存させられない。これは、アプリケーションが依存する複数のライブラリ(A, B)が同じライブラリ(C)に依存するが、しかしそれぞれバージョン制約が異なる、という状況を解決できないことを意味する。

App ---> A ---> C (~> 1.4.0)
    |
    +--> B ---> C (~> 2.0)

これはアプリケーション開発者にとってはかなり厳しい。上記の状況であればライブラリAの開発者に依存関係を更新してもらうようお願いすることになる。Aの開発者は依存関係を更新してその状況でAの動作確認とリリースを行い、その上でアプリケーション開発者がまた依存関係の更新とテストを行うことになる。

危険なライブラリグローバル

ライブラリに設定を行うことがあるが、これはクラス変数を用いて実装されている可能性がある。例えば動作が速いからと使われることも多いojは、以下のように設定を行う。

Oj.default_options[:allow_gc] #=> true
Oj.default_options = {allow_gc: false}

Oj.default_options[:allow_gc] #=> false

これはもちろんOj.parseしたときの動作を変更する。つまり、どこか(自分に責任のない)アプリケーションやライブラリの片隅で設定を変更されると、プロセス内すべてにおけるライブラリの動作に影響を与える。

名前空間による解決

ここに書いた問題は、基本的に名前空間があれば解決できる、と自分は信じている。Rubyの動作をいきなり変える必要はない。オプショナルに指定できる名前空間があればよい、と思っている。

Imを使ってみた

名前空間Rubyで実現する試み(Multiverse Rubyと表現されている)として@shioyamaさんが作ったものがIm*2

github.com

これは次のように、ライブラリのローダを作る。ローダはアクセスされたモジュール・クラス名から、それが定義されているファイル名を特定して、ローダ((実体はModuleの派生モジュール))自身の下部に読み込む。

require "im"
loader = Im::Loader.for_gem
loader.setup # ready!

loader::MyGem # ロードパス中の 'my_gem.rb' が特定され自動的に読み込まれる

これをKaigi中に試してみたんだけど、LFAでは以下の理由があって使えない。またgemなどのライブラリに使うのにも現状だと厳しいと思う。

loader.setupする前の時点でロードパスを確定する必要がある

これはImがZeitwerkをforkして作られているからという実装上の理由が大きいかもしれない。内部的にloader.setupした時点でロードパスにある全てのファイルをリストアップして定数名とautoloadする対象ファイル名の対応表を作るため、loader.setupした後で別のディレクトリにあるファイルからクラス・モジュール定義を読み込もうと思っても難しい。((unloadしてロードパスを追加後にsetupしなおせば可能だけど、なかなか常用には厳しいと思う。))

LFAはアプリケーションの起動時にロードパスを確定できるのでこの点は問題ではないが、通常のRubyに入れる、という機能としては厳しい制約だと思う。

ロード対象のクラス・モジュール名とファイル名の関係に強い制約がある

これもZeitwerk由来だからだが、参照された定数名をフックにして読み込むファイル名を特定する関係上、サポートできないものが多くなる。

loader::MyGem # -> my_gem
loader::MessagePack # -> message_pack だが msgpack.rb を読んでほしい……
loader::StringIO # -> requireする対象は stringio だよね

LFAでは読み込み対象のファイルもそこに書かれているクラス・モジュール名も設定ファイルから指定する*3ため、名前ベースで読み込み対象を決定する規約は不要というか、制約となって使用できない。また一般的に、gemでも難しいと思う。Ruby本体の標準添付ライブラリでもこの命名規約に従っていないものは多い。

拡張ライブラリに対応できない

Kernel#loadの第2引数を用いたやりかたはpure Rubyなライブラリでrequireを使っていないものにはうまく動くんだけど、そうでない場合にはうまくいかない。まあrequireを使われていてもその先がpure Rubyコードならなんとかなるかなと思う((具体的にはKernel#requireを上書きしてloadをwrapモジュールと一緒に呼ぶようにする))けど、拡張ライブラリの場合にはそういった方法も使えない。

欲しいもの

自分が欲しいものは以下のように使える名前空間オブジェクトだ。実体としては、拡張された機能のrequireおよびloadメソッドをもつModuleのサブモジュールになる。これが実現できれば、例に示すように、トップレベ名前空間での衝突の回避、異なるバージョンのライブラリの読み込み、クラス変数の使用による意図しない挙動変化の防止、どれもが実現できる。

この名前空間を実現するモジュールを、以下のコード例ではModuleBoxと呼ぶ。なお@shioyamaさんと話していたときには「Hakoと呼ぶのがよいのでは」ということになっていた*4

box1 = ModuleBox.new # or ModuleBox.new(load_path: $LOAD_PATH + ['...'])
box2 = ModuleBox.new

基本的にはインスタンスを作り、その名前空間内でのみ何かをしたいとき、そのインスタンスに対して操作する。

隠蔽された名前空間での読み込み

# 隠蔽された空間でのスクリプトのload
box1.load('my_client.rb')
box1::MyClient #=> MyClient

box2.require('guild')
box2::Guild #=> Guild

MyClient #=> NameError
Guild #=> NameError

box1::Guild #=> NameError
box2::MyClient => NameError

# import to a different name can be done
MyGuild = box2::Guild
MyGuild.build(...)

異なるバージョンのライブラリの読み込み

box1.require('msgpack', version: '1.6.0')
box2.require('msgpack') #=> latest one

msgpack1 = box1.const_get(:MessagePack)
msgpack1::VERSION #=> "1.6.0"
msgpack2 = box2::MessagePack
msgpack2::VERSION #=> "1.7.0"

クラス変数を用いた設定変更の影響の局所化

require('oj')
box1.require('oj')

oj1 = box1.const_get(:Oj)
oj1.default_options[:allow_gc] #=> true
oj1.default_options = oj1.defualt_options.merge({allow_gc: false})
oj1.default_options[:allow_gc] #=> false

Oj.default_options[:allow_gc] #=> true

これから

自分のアイデアはもちろんただのアイデアなので、まだ何ひとつ実現できていない。関係しそうなコードは読んでみた結果、Ruby本体を変更しないと実現できそうにないのはわかっているので、bugs.ruby-lang.orgにFeatureを出してみて、自分の手元で実装にチャレンジしてみるかなあと思っている。

これを読んだ人には、上記のアイデアがどんなもんに見えるかを考えてみてほしい。印象を聞きたい。 またこの機能だけでは不十分だとか、APIが悪いとか、そういうフィードバックがあればぜひ教えてほしい。

*1:トップレベルにそんなの置くなよ、というのはあるが、まあ……。

*2:イムと読む。ImportのImだから。

*3:AWS Lambdaがそのようになっているので、LFAの設定も当然それを踏襲している

*4:英語話者からしてもHakoというのは響きがよい……らしい。

sqldefをMySQLに対してAWS Lambdaから実行するパッケージを作った

k0kubun/sqldefはすばらしいプロダクトで便利に使ってるんだけど、もちろんDBに接続できる場所から実行する必要がある。で、DBはAWSのprivate VPCにあるのでラップトップやCI環境からやるというわけにはいかない。しょうがないので、現在はEC2インスタンスを作成して使うときだけ起動、終了したら停止してた。んだけど、これがまた面倒なんだよね。起動と停止も面倒だし、なんかあったときに確実に作り直せるようにするには……とか考えるのもダルいし、EC2へのSSHする方法やEC2へリポジトリをチェックアウトする方法も考えないといけないし。

なのでしばらく考えてたんだけど、Lambdaでやれるといいんだよな、という希望を現実的に考えて実装してみた。のがこちら。

github.com

こいつは大変便利。リポジトリをcloneして、プライベートリポジトリからスキーマファイルを読み込むならデプロイキーのファイルをぶちこんだ状態で./build.shを叩けばLambda用のzipファイルが完成するから、あとはこれを指定してLambda関数を作成すればいい。あとはtestでもaws lambda invokeでも他のなんのトリガーででも起動すればよい。どんなプロジェクト向けに作り直すのも簡単。やったね!

「研鑽Rubyプログラミング」はライブラリ作者の知識・技術の幅と深さを拡大する1冊

研鑽Rubyプログラミング」を読んだので、その感想を書く。

なお本書の訳者である角谷さんに本をお贈りいただきました。が、その前から同書のβ版(電子版)を購入していたため、実際にはほとんどをそちらで読みました。*1

本エントリの言いたいことは、様々な状況に対応してコードを書くには知識・技術の幅と深さが重要で、本書はそのための重要なインプットとなるでしょう、です!

総論: さまざまなRubyの書きかたを学べる

さて、この本は初手から対象読者を「中級から上級のRubyプログラマー」としており、本そのものの目的は「まえがき」の先頭にもはっきり書かれています。少し長めに引用します。

 本書の目的は、中級から上級のRubyプログラマーが従うべき有用な原則を伝えることです。解決策をどのように実装するかだけでなく、さまざまな実現方式と、それらの間のトレードオフ、ある方式が特定の状況下ではなぜ有効なのかといった観点も重視します。原則を伝えることが本書の主な目的ですが、Rubyプログラミングの発展的な技法を説明することもあります。

自分はこのまえがきを読み、本文を読み進めていく上で、この本はトレードオフのある選択肢をさまざまに提示する本だというように理解しました。Rubyに限らずどんなプログラミング言語でも同じことですが、ある処理を書くときに、方法がひとつしか無いということはまずありません。成瀬さんの感想エントリにもあるように、"There is more than one way to do it."なのです。この本がすべてのwayを示せているということもありません。しかし、間違いなく高い技術力を持つJeremyの"way"を一例として(あるいはもっと多くの例として)インプットできるのは本書ならではでしょう。

併存する複数のプログラミングスタイル

ところで、いくつも way があってどうすんだ、という話については、それでいいのです、と言いたい。自分の話をちょっと挟ませてもらうと、実際に自分がコードを書くときには、意図してあるいは意図せずして、複数のやりかたを使い分けています。典型的なユースケースを元に名付ければ、以下の3つが代表例です。

例えばアプリケーションコードを書く場合には、他のプログラマ*2に理解しやすいコード、あるいはビジネスロジックを発見・修正しやすいコードにすることを第一に考えます。一方でOSSのライブラリについては、もちろんメンテナンス性は非常に重要なものの、性能的に妥協ができないところで走るコードも多くなります。そういったホットスポットとなりうる部分のコーディングでは余計なオブジェクトはできるだけ生成したくないし、メソッド呼び出しの回数もできるだけ少なくなるようなコードを書くよう、自分のモードが自動的に切り替わっているなと思います。

一方でメタプログラミング面白い!!!!みたいな面白コードを書くときであれば、普通には実現が難しい機能をどうにかして実装するために、効率は二の次でなんとかして処理系の裏をかく、みたいな発想になります。このときは言語機能そのものをどれだけ知っているか、どの機能とどの機能を組み合わせれば面白い効果が得られるか、みたいなことをアイデア幅優先探索するみたいな思考過程になるでしょう。

もちろんこんなにかっちり分かれているわけではなく、グラデーションになっていて、これらの典型的なケースの間で、本書の言葉のとおりに言えば「トレードオフ」を考慮してバランスをとります。OSSのライブラリでも頻繁に呼び出されないような部分はユーザビリティ・可読性が最優先になったり、アプリケーションコードでもアプリ内ライブラリで性能最優先のコードを書くことだってあるでしょう。テストコードを書くときには、厄介な部分をどうにかしてモックするためにメタプログラミングを駆使することもあります。

技術・知識の幅を広げ、同時に深める

自分の話として書きましたが、これら複数のスタイルのプログラミングは、重点を置く場所の違い、幅・深さの差などはあれ、プログラマであれば誰でもやっていることだと思います。そしてこの幅と深さは自然に身につくものではなく、色々なところからのインプットを通じて継続的にメンテナンスする必要があります。1冊の教科書があればよいというものではまったくありません。

その意味で、「研鑽Rubyプログラミング」は非常に良い1冊です。本書の1部・2部では、ライブラリの設計・実装における様々な状況について、主に性能面でのトレードオフを解説しつつ、単純な実装からより効率的な実装まで複数のコード例を出して解説しています。特にライブラリ側の実装について、これほど具体的に複数の例示を並べて見られる本はそうありません。

率直に言って、本書で例示されているコード例は、自分にとって「いやこれはちょっと自分では書かないな」と思えるものもあります。例えば180ページ、第7章「自分のライブラリを設計する」に以下のようなコードがあります。ひとつのメソッド定義で複数のユースケースに対応するためのメソッド引数の定義方法についてです。(説明の簡単のため、ふたつのコード例ブロックを結合)

引数なしでobj.first_recordを呼ばれたときに1オブジェクトのみを返し、かつobj.first_n_records(number: 2)のように呼ばれたときは最初の2オブジェクトをArrayで返すことを目的としたものです。

def first_n_records(number: (only_one = 1), offset: 0)
  reset
  offset.times{next_record}
  ary = []

  while record = next_record
    if !block_given? || yield(record)
      ary << record
      break if ary.length >= number
    end
  end

  only_one ? ary[0] : ary
end

alias first_record first_n_records

このコードのキモは、Rubyのメソッド引数デフォルト値には任意の式が書け、そこでローカル変数の定義と代入もできるという特性を利用しているところで、本文ではこのテクニックの詳細が紹介してあります。

で、このコードを見せられて自分がこう書くかというと、まあ書かないかな、正直意図が読みにくいし……。配列が欲しければobj.first_record(number: 1)とも指定できる、と本文に解説されていますが、numberキーワード引数のデフォルト値は1のはずなのに明示すると返り値が変わるというのはあまりユーザに優しくない気がします。自分が設計するなら以下のようにすると思います。 *3

def first_n_records(number: 1, return_value: (number == 1 ? :obj : :array), offset: 0)
  raise ArgumentError, "2 or more values should be in array" if number > 1 && return_value == :obj
  # 途中省略
  if return_value == :obj
    ary[0]
  else
    ary
  end
end

alias first_record first_n_records

こうすれば、1要素のArrayが欲しい場合にはnumber: 1return_value: :arrayの両方を明示させることで、呼び出し側のコードだけを見て何が返ってくるかが誰にでもわかります。

とはいえ、異論があるからといって、本書のこのパートの価値が落ちるかといえば、そんなことはまったくありません。本書のこのコード例はあくまで人工的な例ですし、そもそも本書がなぜ中級以上のプログラマーを対象としているかというと、そんな対象読者は、このインプットを取り込んだ上で「自分でどう書くかを判断できる」人だという信頼があるからでしょう。

何より、自分は本書を読むまで、メソッド引数のデフォルト値指定においてローカル変数を定義する、というテクニックがまったく頭にありませんでした。これは何かに使えそうです。何に使えるかはまだまったく分かりませんが……何でしょうね。えっへっへ。これは将来書くコードのための、本当に価値のあるインプットです。自分にとっては。

面白小ネタ

前半を読んでいったときの3章、4章が変数およびメソッドの話で、これがウケました。なぜかって、3章でグローバル変数の使いどころに以下のようなコード例が。ブロック内コードの実行時に警告をオフにするやつ。

def no_warnings
  verbose = $VERBOSE
  $VERBOSE = nil
  yield
ensure
  $VERBOSE = verbose
end

で、4章のメソッドについてはけっこうな紙幅をつかって移譲(delegate)の解説が。これってさあ、この組み合わせってさあ、完全にLFAの実装テクニックに自分が書いてたやつじゃん!!!

まあでもdelegateとの組み合わせはともかく、警告をオフにするための便利メソッドはテストヘルパーとかでよく書きますよね。ライブラリとか書いてるとね。拙作ライブラリmaccroにだってもちろんありますよ。ウケるくらい一致してるな。真っ黒……。。。。

本書を読む上での注意点

本書については、タイトルなどで特に言明されていることではないですが、おおむね「Rubyによるライブラリ開発を中心とする」というコンテキストは頭において読んでもいいのではないかな、という気がします。例えば定数の可視性について『一般原則としては、「ユーザーに見せたくない定数はすべてprivate_constantにしておく」のが最善です』という記述がありますが、ユーザー(コードの呼び出し側開発者)と(アプリ内ライブラリ側)開発者が同一となるWebアプリケーションにおいて、定数をそこまでprivate_constant指定して回る必要があるかというと、まあ無いかなと思えます。このように、本書の記述が自分の開発にそのまま適用できるかどうかは常に考えながら読む必要があるでしょう。

また、Jeremy作のSequalAPIを見てもわかるとおり、メソッド命名において#[]を多用する傾向があったりするなど、著者の好みがかなり強く出ている章もあります。特に3部のWebアプリケーションについては、著者の意見が強く前に出ている部分も多く、自分の立場からは異論のある記述も多かったことは書いておきます。

とはいえ、それも想定読者である中〜上級者であれば、インプットの1バリエーションとして受け入れられるのではないでしょうか。

まとめ

ということで、本書は技術を飯の種にするRubyプログラマにとって、インプットとして非常に価値のある一冊だと言えます。全面的にお手本にする教科書ではありませんが、それでも確実に、我々のRubyプログラミングについて、知識と技術を押し広げてくれることでしょう。

*1:内容は完全に同一、ということになっているはず。 link

*2:後年の自分も含む

*3:あるいはaliasをやめて、first_n_recordsを呼び出したときはnumberにかかわらずreturn_valueのデフォルト値はArray、いっぽう def first_record = first_n_records(number: 1, return_type: :obj) のように定義するのもありかなと思います。

SwiftUIでNavigationStackを使っているとNavigation BarやBackボタンが隠せない? (そんなことなかった)

追記

NavigationViewで一度動くコード作ってからNavigationStackに戻してみたらうまく動いて、期待通りに遷移先でBackボタンがない状態が作れた。

            NavigationStack(path: $path) {
                SplashView()
                    .navigationDestination(for: SigninState.self) { state in
                        Group {
                            switch state {
                            case .notsignedin:
                                SigninView()
                            case .signedin:
                                MainView()
                            }
                        }
                        .navigationBarBackButtonHidden(true)
                    }
            }

この遷移先に.navigationBarBackButtonHiddenをつける方法も試したと思ってたんだけど、たぶん何かでミスってたんだと思う。単に自分のミスだったのかな、ということで、解決しました。

以下オリジナル

タイトルに書いたままなんだけど。 iOSアプリの作り直しをやってて、まあiOS16以降のみ対応でもいいかなと思ったのでビュー遷移に最初からNavigationStackを使おうと思ったんだけど、ユーザに勝手に戻ってほしくない遷移があるのでBackボタンを隠そうと思って以下のようなコードを書いた。

NavigationStack(path: $path) {
    SplashView()
        .navigationBarTitleDisplayMode(.automatic)
        .navigationBarBackButtonHidden(true)
        .navigationDestination(for: SigninState.self) { state in
            Group {
                switch state {
                case .notsignedin:
                    SigninView()
                case .signedin:
                    MainView()
                }
            }
        }
    }
}

んだけど、navigationBarBackButtonHidden(true)ドキュメントにもある通り、どうもNavigationView内でしか効かないみたい。NavigationStackといっしょに指定してもやっぱり遷移先でBackボタンが表示されてしまう。

SwiftUI慣れてないから修飾するビューが違うのかな、と思ってGroup{ ... }の部分に指定してもやっぱりダメ。

あとドキュメントをあれこれ見てると.toolbarで指定しろってあるものもあるので .toolbar(.hidden, for: .navigationBar) を指定してみたりもしたんだけど、やっぱりダメ。なぜだ。現状ナビゲーションバーを隠したりBackボタンを非表示にしたりする方法がないのかな。

なんもわからんのでDeveloper Forumに質問を投げておいた。投げる前に同じ問題が投稿されてなさそうなのはいちおう見てる。

How can I hide navigation bars (or… | Apple Developer Forums

amplify-swiftを使ってAmazon CognitoでFederated Loginするときにクライアント設定で不必要なIdentity Providerを有効にしない

ていうか有効にしていたつもりもなかったんだけどamplify-swiftがそう動いてしまう、そしてエラーになる、という話。

結論としては、iOSアプリ側にあるamplifyconfiguration.jsonにおいて不必要なキーCredentialsProviderが存在すると自動的に使われてしまいエラーになるので、キーそのものを削除しなければならない。

経緯

Amazon Cognitoを、Google/Facebookその他IDプロバイダーのOAuth認証を通すために使っているが、IdentityProviderとしては使っていない。Web(React)で作っている画面の方ではJS用のAmplifyライブラリを経由していてログインができるんだけど、iOSアプリでSwiftからログイン用の機能を呼び出すと、なんかうまくいかない。認証用の画面が出てきてIDプロバイダー(Google)での認証が通ったあとに画面が閉じて成功も失敗も処理が走らない、という状況になってしまう。

該当のコードはこんな感じなんだけど、signInWithWebUIが呼ばれたあとのsignInResultが返ってこず、また例外も出てない。:

    @IBAction func tapSignInButton() {
        Task {
            do {
                print("Signing in...")
                let signInResult = try await Amplify.Auth.signInWithWebUI(for: .google, presentationAnchor: self.view.window!, options: .preferPrivateSession())
                print("SignInResult: \(signInResult)")
                if signInResult.isSignedIn {
                    // let attributes = try await Amplify.Auth.fetchUserAttributes()
                    // print("Sign in succeeded, attributes:\(attributes)")
                    print("Sign in succeeded")
                }
                print("Quitting the sign in flow...")
            } catch let error as AuthError {
                print("Sign in failed \(error)")
            } catch {
                print("Unexpected error: \(error)")
            }
        }
    }

なお設定ファイルamplifyconfiguration.jsonは、チュートリアルに従えば作成されるはずなんだけど従っていない*1ので、あれこれ調べて自分で以下のように設定していた。

{
    "auth": {
        "plugins": {
            "awsCognitoAuthPlugin": {
                "IdentityManager": {
                    "Default": {}
                },
                "CredentialsProvider": {
                    "CognitoIdentity": {
                        "Default": {
                            "PoolId": "[IDENTITY_POOL_IS_NOT_USED]",  // これはこの通りに書いてある
                            "Region": "ap-northeast-1"
                        }
                    }
                },
                "CognitoUserPool": {
                    "Default": {
                        "PoolId": "ap-northeast-1_mypool_id",
                        "AppClientId": "my--app--client--id",
                        "Region": "ap-northeast-1"
                    }
                },
                "Auth": {
                    "Default": {
                        "authenticationFlowType": "USER_SRP_AUTH",
                        "OAuth": {
                            "WebDomain": "my-cognito-domain.auth.ap-northeast-1.amazoncognito.com",
                            "AppClientId": "my--app--client--id",
                            "SignInRedirectURI": "myapplicationname://",
                            "SignOutRedirectURI": "myapplicationname://",
                            "Scopes": [
                                "email",
                                "openid"
                            ]
                        }
                    }
                }
            }
        }
    }
}

なお最初はWebDomainhttps://my-cognito-domain...のように設定していたところ、signInWithWebUIが呼ばれたところで「アプリケーションが(null)を開こうとしています」みたいなメッセージが出て白いページが出てきて認証が進まなくなるというウケる状態になってた。そのくらいバリデーションしてほしい……。。。https://を削除したら先に進み、認証が完了するはずなのにうまくいかなくなるという状態になった。

ログを取る

いろいろ調べてる途中、こんなコードを入れているコード例を見掛けた。

Amplify.Logging.logLevel = .verbose

ので、自分のアプリのコード内でもAmplifyの設定してるところで入れてみた。

        do {
            try Amplify.add(plugin: AWSCognitoAuthPlugin())
            try Amplify.configure()
            Amplify.Logging.logLevel = .verbose
            print("Amplify configured with auth plugin")
        } catch {
            print("Failed to initialize Amplify with \(error)")
        }

すると次のようなエラーが出ているのがデバッグコンソールに見えた。

1 validation error detected: Value '[IDENTITY_POOL_IS_NOT_USED]' at 'identityPoolId' failed to satisfy constraint: Member must satisfy regular expression pattern: [\\w-]+:[0-9a-f-]+

CredentialsProviderを削除

設定ファイルから以下の部分を削除。

                 "IdentityManager": {
                     "Default": {}
                 },
-                "CredentialsProvider": {
-                    "CognitoIdentity": {
-                        "Default": {
-                            "PoolId": "[IDENTITY_POOL_IS_NOT_USED]",
-                            "Region": "ap-northeast-1"
-                        }
-                    }
-                },
                 "CognitoUserPool": {
                     "Default": {

これでうまく動くようになった。やれやれ。

*1:サーバサイドの設定は既にあるのでいじられても困るんですよ、チュートリアルに従う以外の手順がドキュメントに見付からないのマジ困る