give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

「プロを目指すRailsエンジニアのための公開コードレビュー」という発表をしました #railsdm

はじめに

2017年8月24日にRails Developers Meetup #4という勉強会で「プロを目指すRailsエンジニアのための公開コードレビュー」という発表をしてきました。

このエントリではこの勉強会の発表内容を紹介します。

f:id:JunichiIto:20170826085817p:plain

発表のテーマを決めるまでの経緯

以前、「WEB+DB PRESS Vol.99の「良いコード」を本気でコードレビューしてみた」というエントリを書いて結構な反響があったのですが、この記事を読まれた主催者の平野さん(@yoshi_hirano)から「良いコードとは何か、というテーマで体系的に語ってもらいたい」と声をかけてもらったのが登壇のきっかけでした。

ただ、「良いコードを体系的に語る」となると、僕の中ではおそらく「CODE COMPLETEやリーダブルコードを読め。以上!」になってしまうので、それよりも具体的なコード例を見ながら「ここはこう」「このコードはこう」と説明する発表の方がいいだろうなと思いました。

また、具体的なコードを見ながら良し悪しを語るにしても「お互いに知っているコード」でないと、なかなかぱっと理解することができません。
そこで、

  • 僕の方で簡単なプログラミング問題を作る
  • それをみんなに解いてもらう
  • さらに、いただいた解答をいくつかピックアップして発表の中でコードレビューする

というスタイルにするのはどうか、と提案しました。

平野さんも「いいですね。面白そう!」と言ってくれたので、ちょっと珍しい「公開コードレビュー」という形式の発表をやることになりました。

CODE COMPLETE 第2版 上 完全なプログラミングを目指して

CODE COMPLETE 第2版 上 完全なプログラミングを目指して

リーダブルコード ―より良いコードを書くためのシンプルで実践的なテクニック (Theory in practice)

リーダブルコード ―より良いコードを書くためのシンプルで実践的なテクニック (Theory in practice)

  • 作者: Dustin Boswell,Trevor Foucher,須藤功平,角征典
  • 出版社/メーカー: オライリージャパン
  • 発売日: 2012/06/23
  • メディア: 単行本(ソフトカバー)
  • 購入: 68人 クリック: 1,802回
  • この商品を含むブログ (137件) を見る

問題の概要

僕が作った問題はTrainTicketRailsという、電車の改札口を簡易的にシミュレートしたRailsアプリです。
あらかじめある程度コードができていて、そこに指定された仕様を満たすコードを書いてもらう、というのが今回のお題になります。

詳しくは以前書いたこちらのエントリをご覧ください。

blog.jnito.com

ちなみにこの問題は2017年11月に発売予定の書籍「プロを目指す人のためのRuby入門」のために作った例題をRails向けにモディファイしたものです。

blog.jnito.com

当日は自宅からリモート登壇!

この勉強会はなんと、僕は自宅から発表する「リモート登壇者」でした。
さらに、登壇者だけでなく、勉強会の会場もメイン会場の東京会場に加えて、東京会場と中継でつなく大阪会場と、自宅から視聴可能なリモート会場もありました。

発表する側も、発表を聞く側も、リモートでつながる勉強会とは何とも未来的ですね~。

ちなみに当日はGoogleハングアウトとYouTubeライブを使ってリモート登壇&リモート視聴をしていました。
音声や映像が途切れがちになったりしないかちょっと心配でしたが、結構きれいに中継できていたみたいです。

当日の発表スライドはこちら

当日使ったスライドはこちらです。

ただ、当日はGitHub上のpull requestを見ながらお話をしたので、スライドにはあまりコードが出てきません。
そこでこのブログの中でざっくりとレビュー内容を紹介していきます。

一人目の方のコード

最初はこちらの方のコードをレビューさせてもらいました。

https://github.com/JunichiIto/train-ticket-rails/pull/27

コントローラのコードについて

コントローラのコード(tickets_controller.rb)はこんなふうに実装されていました。

   def edit
+    load_ticket
+    redirect_to root_path, notice: '降車済みの切符です。' if @ticket.used?
   end
 
   def update
-    if @ticket.update(ticket_update_params)
-      redirect_to root_path, notice: '降車しました。😄'
+    load_ticket
+    exited_gate = Gate.find(ticket_update_params[:exited_gate_id])
+
+    if @ticket.used?
+      redirect_to root_path, notice: '降車済みの切符です。' if @ticket.used?
+    elsif exited_gate.exit?(@ticket)
+      update_ticket
     else
+      @ticket.errors[:base] << '降車駅 では降車できません。'
       render :edit
     end
   end

 (略)

   def load_ticket
     @ticket = Ticket.find(params[:id])
   end
+
+  def update_ticket
+    if @ticket.update(ticket_update_params)
+      redirect_to root_path, notice: '降車しました。😄'
+    else
+      render :edit
+    end
+  end

こちらのコードに対しては以下のようなコメントをさせてもらいました。

  • load_ticketメソッドはbefore_actionで呼ばれているので、editupdateの中で呼び出す必要はない。
  • “降車済みの切符です。"のメッセージを表示するコードが2回出てきているので、DRYにするためにメソッドに切り出してbefore_actionで呼び出すようにしたい。
  • Gate.findのようにfindメソッドを使っている部分があるが、ここは@ticket.exited_gateのようにRailsの関連をうまく使ってデータを取ってくるようにしたい。
  • updateメソッド内にたくさん条件分岐が出てきて、「ファットコントローラ」になっている。ロジックはできるだけモデルに持っていって、「薄いコントローラ」を目指したい。
  • コントローラでデータのチェックをすると、モデル単体でデータを保存したときに「同じ駅で降りるな」「料金不足なら降車不可」といったデータのチェックを実行できない。確実にデータがチェックされるよう、検証ロジックはモデルに持ってくるべき。

モデルのコードについて

モデルのコード(gate.rb)はこんなふうに実装されていました。

   def exit?(ticket)
-    true
+    gate_interval = fetch_gate_interval(ticket.entered_gate_id)
+
+    # 運賃不足ではないかチェック
+    if gate_interval.zero?
+      false
+    elsif FARES[gate_interval - 1] <= ticket.fare
+      true
+    else
+      false
+    end
+  end
+
+  private
+
+  def fetch_gate_interval(entered_gate_id)
+    (
+      entered_station_number(entered_gate_id) - station_number
+    ).abs
+  end
+
+  def entered_station_number(gate_id)
+    Gate.find(gate_id).station_number
   end

僕の指摘内容は以下のとおりです。

  • このコードでもGate.findが登場しているが、ticket.entered_gate.station_numberのようにしてRailsの関連を使った方がスマート。
  • exit?メソッドではtruefalseを明示的に返しているが、<=の比較結果をそのまま返せばtruefalse明示的に返す必要がない。

二人目の方のコード

二本目はこちらのコードをレビューさせてもらいました。

https://github.com/JunichiIto/train-ticket-rails/pull/15/

コントローラのコードについて

コントローラは次のようになっていました。

   def load_ticket
     @ticket = Ticket.find(params[:id])
+    redirect_to root_path, alert: '降車済みの切符です。' if @ticket.exited_gate_id
   end

僕のコメントは以下のとおりです。

  • コントローラにほとんど手を加えず、たった1行の変更で済ませた点は非常にスマート(「薄いコントローラ」を維持できている)。
  • ただし、load_ticketメソッドの中で、データをチェックして問題があればリダイレクトさせるのは、load_ticketメソッドの責務を超えているように思う。
  • メソッドの責務が増えると再利用性も低下してしまうので、ここは別メソッドに分けてbefore_actionで呼び出す方がベターではないか。

モデルのコードについて

改札口モデル(gate.rb)のコードは次のようになっていました。

   def exit?(ticket)
-    true
+    price = calculate(ticket)
+    price > 0 && ticket.fare - price >= 0
+  end
+
+  private
+
+  def calculate(ticket)
+    section = (station_number - ticket.entered_gate.station_number).abs
+    section > 0 ? FARES[section - 1] : 0
   end

以下は僕からのコメントです。

  • こちらもシンプルでスマート。ticket.entered_gate.station_numberのように、Railsの関連もちゃんと使えている。
  • ただし、calculateメソッドが0(ゼロ)を返すところが気になる。この0は「無料だから降車OK」の意味ではなく、「同じ駅で降りようとしているので降車不可」のフラグになってしまっている。
  • 作った人だけがわかる暗黙の意味、暗黙のルールはできるだけ避けるべき。プログラムが大きくなったり、開発する人が増えたりすると、「0の運賃」がそのまま「無料」と解釈される恐れがある。

もう一つ、切符モデル(ticket.rb)にも次のような変更が入っていました。

 class Ticket < ApplicationRecord
   belongs_to :entered_gate, class_name: 'Gate', foreign_key: 'entered_gate_id'
-  belongs_to :exited_gate, class_name: 'Gate', foreign_key: 'exited_gate_id', required: false
+  belongs_to :exited_gate, class_name: 'Gate', foreign_key: 'exited_gate_id', optional: true
   validates :fare, presence: true, inclusion: Gate::FARES
   validates :entered_gate_id, presence: true
+
+  validate :must_be_exit, if: :exited_gate_id
+
+  private
+
+  def must_be_exit
+    errors.add(:exited_gate, 'では降車できません。') unless exited_gate.exit?(self)
+  end
 end

僕のコメントは以下のとおりです。

  • requiredオプションが非推奨になって、optinalオプションを使うようになっていたのは知らなかった(僕の方が勉強になった!)
  • must_be_exitメソッドの実装は問題なし。ただし、must_be_exitだと「be動詞 + 一般動詞」になってしまうので、must_be_exitablecan_exitのようにした方が英文法的には良さそう。

僕の解答例

僕の解答例はGitHubのanswerブランチに置いてあります。

https://github.com/JunichiIto/train-ticket-rails/compare/master…answer

コントローラのコードについて

コントローラのコードは以下のとおりです。

「使用済みのチケットが指定されたらトップページへリダイレクト」のコードはbefore_actionを使って実現しています。

 class TicketsController < ApplicationController
   before_action :load_ticket, only: %i(edit update show)
+  before_action :abort_exited_ticket, only: %i(edit update)
 
   def index
     redirect_to root_path

 (略)
 
   private
 
+  def abort_exited_ticket
+    if @ticket.exited?
+      redirect_to root_path, notice: '降車済みの切符です。'
+    end
+  end
+
   def ticket_create_params
     params.require(:ticket).permit(:fare, :entered_gate_id)
   end

モデルのコードについて

改札口モデルのコードは以下のとおりです。

exit?メソッドではまず、「同じ駅で降りようとしていたらfalseを返す」処理を行い、それから運賃を計算して切符の運賃と大小比較しています。

  def exit?(ticket)
-    true
+    return false if ticket.entered_gate == self
+    ticket.fare >= calc_fare(ticket)
+  end
+
+  private
+
+  def calc_fare(ticket)
+    from = ticket.entered_gate.station_number
+    to = station_number
+    distance = (to - from).abs
+    FARES[distance - 1]
   end

続いて、切符モデルのコードも見てみましょう。

切符モデルでは独自の検証メソッド(gate_exit_should_be_successful)を定義して、仕様上おかしなデータが保存されないようにチェックしています。

また、exited?メソッドは「使用済みかどうか(降車済みかどうか)」を返すインスタンスメソッドです。

+  validate :gate_exit_should_be_successful, if: -> { exited_gate.present? }
+
+  def exited?
+    exited_gate.present?
+  end
+
+  private
+
+  def gate_exit_should_be_successful
+    unless exited_gate.exit?(self)
+      errors.add(:exited_gate, 'では降車できません。')
+    end
+  end

さらに:全員分のコードレビューもしてみました!

もともとはこちらでピックアップした2名のコードをレビューして終わり、とする予定でした。
ですが、他のみなさんのコードも非常に興味深いものが多かったので、思い切って全員分レビューしてみることにしました!

勉強会の時と同じようにpull requestを見ながら口頭でコメントし、その様子を動画に撮ってYouTubeで公開しています。

ちょっと長いので前編と後編の2部構成になっています。

本編では話していない話題もたくさん出てくるので、こちらもぜひチェックしてみてください!
(ただし、長いので1.5倍速ぐらいで視聴することをオススメします)

Twitter上の反響

Twitterの反響を見ていると思った以上に楽しんでいただけたようで、登壇者としても非常に嬉しかったです。

勉強会全体のツイートをご覧になる場合はこちらのTogetterをどうぞ。
僕が発表した時間帯のツイートは3ページ目から9ページ目あたりにあります。

togetter.com

まとめ

というわけでこのエントリではRails Developers Meetup #4で発表した「プロを目指すRailsエンジニアのための公開コードレビュー」の内容をあれこれ書いてみました。

主催者の平野さんをはじめ、運営スタッフのみなさん、どうもありがとうございました。 入念な準備のおかげでスムーズに発表することができました!

また、公開コードレビューで解答をピックアップさせてもらったお二人をはじめ、解答を投稿してくれたみなさんもどうもありがとうございました!
解答が来なかったらどうしよう?と心配していたのですが、解答を投稿してもらったおかげでとても実りのあるコードレビューができたと思います。

これからRubyやRailsの勉強をやっていこうと思われている方は、ぜひ今回の発表内容を参考にしてみてください。
あと、2017年11月に発売される「プロを目指す人のためのRuby入門」も良いコードを書くのに役立つと思うので、こちらもぜひよろしくお願いします!(宣伝)

blog.jnito.com

告知:Rubyプログラミングキャンプ2017、参加者募集中です!

僕とAkiさん(@spring_aki)で主催している西脇.rb&神戸.rbでは現在「Rubyプログラミングキャンプ 2017」の参加者を募集中です!

このイベントは2日間、泊まり込みで思いっきりRubyプログラミングに没頭しようという楽しいイベントです。
まだ若干枠が残っているので、興味がある方はぜひご参加ください。

Ruby初心者の方や、僕たちの勉強会に一度も参加したことがない、という方も大歓迎です。
もちろん僕も参加するので、「伊藤さんに良いコードの書き方を教わってみたい」という方もぜひ!(苦笑)

nishiwaki-koberb.doorkeeper.jp

あわせて読みたい

今回登壇するきっかけになったコードレビューのエントリです。
自分で読み返してもかなり細かいな・・・。

blog.jnito.com

僕のコードレビューが細かくて、良いコードや良い設計へのこだわりが強いのはこんな背景があるからです。

blog.jnito.com

Everyday Rails(Rails 4.1版) 第9章「モックとスタブ」に関するQ&A(とお詫び)

はじめに

Everyday RailsのQ&Aページで本の内容に関する質問をいただきました。
回答を書いているとかなり長くなってしまったのと、他の読者のみなさんにも知っておいてもらいたい内容なので、このブログに回答を書きます。

質問

Everyday RailsのQ&Aページより)

本とGithubのコードが異なります。
テストの実行結果が変わらないことと私の理解不足で成否が判断できないのですが本の方が正しいのでしょうか?

https://github.com/everydayrails/rails-4-1-rspec-3-0/blob/master/spec/controllers/contacts_controller_spec.rb

コードでは58行から全部「Contact」が大文字

allow(Contact).to receive(:persisted?).and_return(true)
allow(Contact).to \
  receive(:order).with('lastname, firstname').and_return([contact])
allow(Contact).to \
  receive(:find).with(contact.id.to_s).and_return(contact)
allow(Contact).to receive(:save).and_return(true)

本では小文字「contact」もある

allow(contact).to receive(:persisted?).and_return(true)
allow(Contact).to \
  receive(:order).with('lastname, firstname').and_return([contact])
allow(Contact).to \
  receive(:find).with(contact.id.to_s).and_return(contact)
allow(contact).to receive(:save).and_return(true)

回答

最初に結論からいうと、実はallowで始まる4行のうち、ここで必要となるコードは以下の行だけです。

allow(Contact).to \
  receive(:find).with(contact.id.to_s).and_return(contact)

つまり、本のコードもGitHubのコードも間違っています(というか無駄なコードを書いています。ごめんなさい)。

試しに次のように他の行をコメントアウトしてからテストを実行してください。
おそらくテストはパスするはずです。

before :each do
  # allow(Contact).to receive(:persisted?).and_return(true)
  # allow(Contact).to \
  #   receive(:order).with('lastname, firstname').and_return([contact])
  allow(Contact).to \
    receive(:find).with(contact.id.to_s).and_return(contact)
  # allow(Contact).to receive(:save).and_return(true)

  get :show, id: contact
end

もし全部コメントアウトするとエラーが発生すると思います。
なので、findのスタブだけは必要なことがわかります。

before :each do
  # allow(Contact).to receive(:persisted?).and_return(true)
  # allow(Contact).to \
  #   receive(:order).with('lastname, firstname').and_return([contact])
  # allow(Contact).to \
  #   receive(:find).with(contact.id.to_s).and_return(contact)
  # allow(Contact).to receive(:save).and_return(true)

  get :show, id: contact
end
ActiveRecord::RecordNotFound: Couldn't find Contact with 'id'=1031
./app/controllers/contacts_controller.rb:75:in `set_contact'
./spec/controllers/contacts_controller_spec.rb:65:in `block (4 levels) in <top (required)>'
./spec/rails_helper.rb:40:in `block (3 levels) in <top (required)>'
./spec/rails_helper.rb:39:in `block (2 levels) in <top (required)>'
-e:1:in `<main>'

もう少し詳しく

このスペックで実行されるコントローラのアクションはContactsControllerのshowです。

ContactsControllerからshowアクションで実行される部分だけを抜粋すると次のようになります。

class ContactsController < ApplicationController
  before_action :authenticate, except: [:index, :show]
  before_action :set_contact, only: [:show, :edit, :update, :destroy]

  # 省略

  def show
  end

  # 省略

  private

    def set_contact
      @contact = Contact.find(params[:id])
    end

    # 省略
end

このうち、ActiveRecordに関連する処理はset_contactメソッド内のContact.findだけになります。
なので、データベースへのアクセスをなくして高速化したいのであれば、この処理をスタブに置き換える必要があります。
そのためのコードをスペックのbeforeフックに書きます。

# Contactクラスのfindメソッドが呼ばれたら、モックの連絡先を返す
# (withはfindメソッドの引数の条件指定。この引数と一致したときにモックの連絡先を返す)
allow(Contact).to \
  receive(:find).with(contact.id.to_s).and_return(contact)

この行までコメントアウトしてしまうと、Contact.findは実際にデータベースにアクセスしてしまいます。
しかし、データベースにはテストデータを登録していないので、レコードが見つからずにエラー(ActiveRecord::RecordNotFound)が起きます。

また、ContactsControllerのshowアクションではContacts.find以外、ActiveRecord関連の処理が実行されないため、これ以外のモック化(allowで始まるコード)は必要ありません。

今回の「無駄なコード」が必要になるケース

さらに、今回「無駄なコード」とされた以下の3つのコードについても見ていきます。
以下は本のコードからの抜粋です。

allow(contact).to receive(:persisted?).and_return(true)

allow(Contact).to \
  receive(:order).with('lastname, firstname').and_return([contact])

allow(contact).to receive(:save).and_return(true)

Contactとcontactの違い

まず、Contactcontactの違いを確認しておきましょう。
大文字のContactはContactクラスそのもの(クラスオブジェクト)です。
一方、小文字のcontactlet(:contact)で作成されたモックの連絡先オブジェクトです。

この点を押さえた上で、3つの「無駄なコード」が必要になる場面を考えていきます。

1つめのコードについて

それでは最初のコードの意味を確認しましょう(コードの意味はコメントに記述しています)。

# モックの連絡先に対してpersisted?メソッドが呼ばれたら、trueを返す
allow(contact).to receive(:persisted?).and_return(true)

このコードに関しては以下のようなことが言えます。

  • テスト実行時にpersisted?メソッドが呼び出されるようなアクションがあるなら、このコードには意味が出てくる。
  • trueを返しているので、モックの連絡先はレコードが保存済みであることを示す。つまり、もしこのコードを使うなら、おそらくeditアクションやupdateアクション、destroyアクションになるはず。
    • ただし、本書のサンプルアプリケーションの場合、persisted?メソッドはフレームワーク内で呼ばれることになるので、どのアクションで呼ばれるのか正確に予測するのは難しい。
  • persisted?メソッドはインスタンスメソッドなので、Contact.persisted?をスタブ化しても意味がない(そんなクラスメソッドはないのでどこからも呼ばれない)。つまり、allowメソッドの引数はContactではなく、contactが正解。

2つめのコードについて

2つめのコードも同じように見ていきます。

# Contactクラスに対してorderメソッドが呼ばれたら、配列に入ったモックの連絡先を返す
# (orderメソッドの引数が'lastname, firstname'の場合のみ発動する)
allow(Contact).to \
  receive(:order).with('lastname, firstname').and_return([contact])

つまり、実行時にContact.order('lastname, firstname')が呼ばれるテストでスタブを使うなら、このコードには意味が出てきます。

また、ContactsControllerのindexアクションを見てみると次のようになっているので、もしこのコードを使うのであれば「頭文字を指定せずに連絡先を検索するスペック」になるはずです。

def index
  if params[:letter]
    @contacts = Contact.by_letter(params[:letter])
  else
    @contacts = Contact.order('lastname, firstname')
  end
end

3つめのコードについて

続いて、最後のコードです。

# モックの連絡先に対してsaveメソッドが呼ばれたら、trueを返す
allow(contact).to receive(:save).and_return(true)

ContactsController内でsaveメソッドが呼ばれるのはcreateアクションだけです。

def create
  @contact = Contact.new(contact_params)

  respond_to do |format|
    if @contact.save
      format.html { redirect_to @contact, notice: 'Contact was successfully created.' }
      format.json { render :show, status: :created, location: @contact }
    else
      format.html { render :new }
      format.json { render json: @contact.errors, status: :unprocessable_entity }
    end
  end
end

なので、もし使うのであれば「連絡先を新規作成するスペック」になります。
さらに、この場合だとContact.newがモックの連絡先を返すようにスタブ化する必要もあります。

また、persisted?メソッドと同様、saveメソッドもインスタンスメソッドなので、allowメソッドの引数はContactではなく、contactが正解です。

まとめ

というわけで内容をまとめます。

  • allowメソッドを使って特定のメソッド呼び出しをスタブ化する場合は、テスト実行時にどのメソッドが呼ばれるのかをチェックし、実際に呼ばれるメソッドだけをスタブ化する(呼ばれないメソッドをスタブ化しても意味がない)。
  • クラスメソッドとインスタンスメソッドの区別を付けた上で、allowメソッドに渡す引数を決める(クラスメソッドをスタブ化するならallow(Contact)、インスタンスメソッドならallow(contact))。

長々と書きましたが、ひとことで言うと「本もGitHubも間違いでした」ということになります。
疑問を抱かせてしまって大変申し訳ありませんでした🙇

なお、修正についてですが、本やGitHubのコードを修正しようと思うと原文の修正も必要になるため、Aaronさんとの調整に時間がかかりそうなのと、英語版はすでにRails 5版に移行しているという観点から、本の内容もGtiHubのコードもいったんこのままにさせてください。

Rails 5版ではモックやスタブを使うサンプルコードも完全にリニューアルしています。
具体的なスケジュールは未定ですが、日本語版もRails 5版に無料アップデートする予定です。

blog.jnito.com

あわせて読みたい

RSpecのモックについては僕もQiitaに記事を書いているので、よかったらこちらも参考にしてください。

qiita.com

VirtualBox(ホスト=Mac、ゲスト=Windows 10)でSDカードを認識させる方法

はじめに

先日、とある要件でWindowsマシンでSDカードを読み込ませる必要が出てきたので、MacにインストールしたVirtualBox + Windows 10の環境でSDカードを読み込んでみました。
ネットを検索していると、意外と「これ!」という情報が見つからなかったので、方法をメモしておきます。

確認環境

今回僕が使っていたのは以下の環境です。

  • MacBook Pro Retina 15-inch(Mid 2015)
  • VirtualBox Version 5.1.14 r112924
  • ゲストOS = Windows 10 Home 日本語版
  • SDカードリーダー = バッファロー BSCRA26U2

iBUFFALO カードリーダー/ライター 43+7メディア対応 ホワイト BSCRA26U2WH

iBUFFALO カードリーダー/ライター 43+7メディア対応 ホワイト BSCRA26U2WH

「あれ?Mac本体のSDカードスロットは使わないの?」

僕が使っているMacBook Proには本体にSDカードスロットが付いています。

f:id:JunichiIto:20170821042557j:plain

最初はMac本体のSDカードスロットを使う方がスマートだよな~、と思ったんですが、ネットを見ていると結構ややこしい手順が必要になる感じだったので、おとなしく外付けのSDカードリーダーを使うことにしました。

手順

手順はこんな感じです。

  1. ゲストOSをシャットダウンする
  2. MacにSDカードリーダーを接続する(SDカードも入れておく)
  3. ゲストOSのSettings画面 > Ports > USBを開き、SDカードリーダーのデバイスを追加する(僕の場合は「Generic Mass Storage Device」という名前だった)
    f:id:JunichiIto:20170821043546p:plain
  4. ゲストOSを起動する
  5. OSの起動直後にSDカードが認識されていない場合は、何度かSDカードやSDカードリーダーを抜き差ししていると、そのうち認識される
    f:id:JunichiIto:20170821044146p:plain

僕の場合はこんな手順でSDカードを認識させることができました。

ちなみにSDカードを使う要件はカーナビの地図データ更新でした

なんでわざわざWindowsマシンでSDカードを読み込まなくちゃいけなかったのかというと、僕が使っているカーナビの地図データを更新(SDカードに地図データをコピー)するためでした。
僕はパイオニア(carrozzeria)のカーナビを使っているのですが、このカーナビの地図データ更新に使う「ナビスタジオ」というアプリケーションがWindowsじゃないと動かないんです。

carrozzeria | ナビスタジオ

ただ、このWindowsアプリもなんか変なクセがあって、地図データをダウンロード > SDカードに転送 > データの検証、と進んだときに、毎回「カードの読み込みに失敗しました」みたいなエラーメッセージが表示されて、地図データのアップデートに失敗しました。

「なんで毎回エラーが出るねん!!」とイライラしていたのですが(失敗すると最初からやり直しで30分以上時間かかる)、最終的にはMac側もWindows側も「ナビスタジオ」以外のアプリケーションは一切起動しない、という方法をとると、なんとか最後までアップデートができました。
(他のアプリがアップデートの邪魔をしていたのかどうかはハッキリしませんが・・・)

まとめ

というわけで、このエントリではVirtualBox(ホスト=Mac、ゲスト=Windows 10)でSDカードを認識させる方法を紹介してみました。

最近では「Windowsじゃないとどうしてもできない」という場面に遭遇する機会は滅多になくなったのですが、たまーにこういうこともあるんですよねえ。
(てか、Macでも地図データを更新できるようにしてよ、パイオニアさん!)

やんごとなき理由でどうしてもSDカードを使いたい、という場合はぜひ参考にしてみてください。