谷本 心 in せろ部屋 このページをアンテナに追加 RSSフィード

2018-01-22

[][]アケコンの天板を自作ステッカーでカスタムした話

FEXLのためにRAP.N HAYABUSAを買ってアケコンデビューしたわけですが、アケコンについて調べていると、ボタンや天板をカスタマイズしている事例をよく目にします。特に痛々しい痛アケコンなど見るにつけ、自分もやりたい気持ちが高まってまいりました。

またそもそもアケコンの天板やボタンは消耗品みたいなものなので、ボタンや天板を交換することで、アケコン自体の寿命も延ばせそうです。

そう思いながらRAPの天板交換をしている事例を見ていたのですが、いずれも接着されている天板を剥がしてしまい、カットしたアクリルパネルと印刷した紙に差し替えています。いやいや、買ったばっかのアケコンの天板を剥がすとか貧乏性なのでできないし、のちのち譲ったり売ったりする時にも困りそうです。


もっと普通に、アケコンの天板と同じサイズのステッカーを作って貼ればいいんじゃないの?


って思いました。作業工程が大げさにはならなさそうですし、ステッカーを貼るなら(それなりに剥がしやすいステッカーであれば)天板保護の役割にもなるはずです。そう思って事例を探してみたのですが、全く見つからず。仕方ないので、自分で考えて挑戦することにしました。


先に結果を出しておきますが、いまのRAP.Nがこれです。とても良いです。

f:id:cero-t:20180123011255j:image:w640

印刷屋さんにお願いして、印刷やボタン周りのカットなど行ってもらって、5枚で3000円程度でした。1枚数百円って考えると、だいぶコスパがいいですね。


では、自分がやったことを時系列で書いていきます。


1. 天板の画像づくり

テンプレートは、Art's Hobbiesのサイトにあったものを使わせて頂きました。

http://tek-innovations.com/arthobbies/index.cfm?loc=about&sub=templates

RAP.NはソウルキャリバーVアケコンと同形状ですので、「HORI Soul Calibur V Panel Template」と記載されているものを使います。

Photoshopや、FireAlpacaGIMPなどのpsdファイルを編集できるツールで画像を開き、自分の好きな画像を差し込んで、拡大縮小などしながらいい感じの配置にします。


2. 最初は自分で印刷してカットしました

ステッカーの印刷やカットは業者にお願いするのが品質的に良いと分かっていたのですが、そもそもステッカーを作って貼るなんてできるのかどうか、ステッカーの厚みでボタンがハマらなくなることはないのか、そういう不安がいくつもあったので、業者に発注する前に、自分でステッカーを印刷してアケコンに貼ってみることにしました。

2-1. 手作りステッカーのシートを買う

家庭用のインクジェットプリンターを使って、ステッカーを自作するような製品がいくつかりますが、今回はこれを選びました。

エーワン 手作りステッカー キレイにはがせる 3セット 28874

エーワン 手作りステッカー キレイにはがせる 3セット 28874

保護フィルムで防水でき、また、キレイにはがせるタイプのものです。アケコン用なので防水にしたかったというのと、はがす時に天板を痛めたくなかったのでこのタイプにしました。

2-2. A3印刷できないので、A4で2枚に印刷

なおRAP.NはA3相当のサイズなのですが、我が家のプリンターではA4までしか印刷できません。またコンビニでも用紙持ち込みの印刷は難しそうなので、A3サイズの画像をA4 x 2枚に分割して印刷することにしました。

Photoshopなどの画像編集アプリで画像を2つに分割して(一部、重なるようにするのがコツ)それぞれをA4サイズで印刷します。印刷をして保護フィルムも貼ったら、2枚のシートの印刷の重複した部分がちょうどいい感じになるように重ね、カッターでまっすぐ切ります。

・・・この辺りの写真を撮ってなかったので文字で説明したんですが、全く伝わりませんね。すみません。

2-3. デザインナイフとコンパスカッターで切断。ダイソーは神

2枚に切れたら、あとは枠やボタンの切り抜きです。枠に沿ってデザインナイフ(ダイソーで買ったよ!)でカットし、ボタンやレバーの周りをコンパスカッター(ダイソーで買ったよ!)でカットしました。あとカットする時には、カッティングマット(ダイソーで買ったよ!)があると便利ですね。というかダイソー、なんでもありすぎてヤバくないですか?

2-4. レバーとボタンはきちんと外す

カットが終われば後は貼るだけ。本当は裏蓋を開けずに(レバーやボタンを外さずに)貼り付けたかったのですが、特にレバーを外さずに貼るのは不可能でした。

裏蓋を開け、レバーとボタンを外し、天板を止めているビスも外して、天板(とその裏の金属板)だけを取り外します。このあたりの取り外し方法はググればすぐに出てくるので割愛します。

f:id:cero-t:20180117005244j:image:w640

2-5. 完成と感想

天板にステッカーのシート2枚をぴったり貼り付ければ、完成です。こんな感じになりました。

f:id:cero-t:20180117005014j:image:w640


パット見かなりいい感じなのですが、ビスの周りがグチャっとなってるし、ボタンの周りは念を入れて広く切り過ぎたし(むしろ切り足りないぐらいで良かった)、何よりアケコンの中央あたり、A4シートの2枚の境界線あたりが失敗してしっかり下地が見えてしまっています。あと気泡がいっぱい入ってます。

f:id:cero-t:20171227023521j:image:w640


そんな残念なところはありつつ、なんというかかなり満足度がありました。痛アケコンってこんなにも満足度が得られるもんなんだな、という感じです。気泡とか下地が見えてるところがあっても満足度が高いんだから、やっぱステッカーを業者に発注してちゃんと印刷しよう、となりました。


3. 業者に頼もう

自分でステッカーを作成した場合、ステッカーシートとカッターや何やかんやを買ったおかげで、結局1500-2000円ぐらい掛かりました。じゃぁ印刷業者はいくら掛かるのよと思って調べてみたら、なんと1枚なら2000円程度、5枚でも3000円ぐらいじゃないですか。

こんなに安いなら最初から業者に頼めば良かった、僕の苦労は何だったんだと、やや大きめの後悔がありました。自作した経験、プライスレス、ということで(ちょっと古い)

気を取り直して、業者に頼んでみましょう。印刷業者は、ITコミュニティ界隈ではよく利用される、デジタというお店にしました。

https://www.digitaprint.jp/

3-1. Photoshopでカットパスを入れる

業者に頼めば印刷だけでなく、カットまでしてくれます。要するに、レバーやボタンの周りなどにカットを入れてもらえば、自分でカットしなくても良いわけです。こういう感じになるってことです。

f:id:cero-t:20180117005706j:image:w640


ただその際に必要なのが、画像ファイルへの「カットパス」の定義。ステッカーのどの部分をカットするのかという定義です。このカットパスはPhotoshopIllustratorでなければ入れることができません。

今回はPhotoshopを使ってカットパスの定義を行いました。やり方は「カットパス」でググってください。

テンプレートの黒枠にぴったり合うよう、円形のカットパスや直前と曲線を組み合わせたカットパスを定義します。たぶん2時間ぐらいかかったと思うんですが、もしかして自分カットパス職人になったのかなと誤解するぐらい、思い通りにカットパスを描けるようになりました。

カットパスを入れるのはそれなりに面倒なので、カットパス入りのファイルが欲しい人は、ツイッター( @cero_t )かなにかで連絡をくだされば、お渡しできます。

3-2. 業者に発注

カットパスの定義まで終われば、業者への発注です。なお画像ファイルの入稿のため、画像ファイルから余計なレイヤーを削除して、画像とカットパスのみにしておきます(きちんと元ファイルも残しておきましょうね)

さて、発注する際にまず決めるのが「紙質」「シールのオプション」「枚数」の3つ。

まず紙質ですが、大きく分けてテカテカする光沢系と、サラサラした手触りのマット系の2種類があります。今回はアケコンならサラサラした手触りが良いと思ってマット紙系の「合成紙ユポ」を選びました。またシールのオプションは「再剥離」を選びました。やはり剥がす時に天板にダメージを与えたくありません。そして枚数は、仲間内で配ることも考えて5枚にしました。というか1枚に比べれば5枚はだいぶコスパが良かったので、自然と5枚を選んだという感じでした。

あとは、印刷サイズは900平方cm以内、ハーフカット11カット、カットパス60以内など、入稿するファイルに合わせて指定しました。

3-3. 届いたので、貼る!

入稿から2週間でステッカーが届きました。

f:id:cero-t:20180116190134j:image:w640


ボタン穴などもステッカーになってるので、自分でカットする必要なく簡単に穴を開けられます。

f:id:cero-t:20180117005706j:image:w640


あとはステッカーを自作した時と同じ手順で、レバーとボタンを外し、天板のビスも外して貼り付けます。

f:id:cero-t:20180117010426j:image:w640


なお天板のビス穴は小さすぎて印刷屋ではカットができませんでした。そのため天板にステッカーを貼った後、ビス穴の中央あたりにカッターで切れ込みを入れます。それで上からビスをはめれば、ビス穴が見えなくなります。

またビスはあまり強く締めすぎるとステッカーが巻き込まれて歪んでしまうため、弱めの力で締めたほうが良いでしょう。


これで見事にカスタムアケコンができあがりました。

f:id:cero-t:20180123011255j:image:w640


4. ふりかえりと次に向けて

うまく行ったように見えるステッカー貼りですが、よくよく細部を見てみると、少しだけズレていることが分かります。

f:id:cero-t:20180123012745p:image:w400


これ、印刷機材やカット機材の都合で100%ぴったりというのはなかなか難しいと思います。なので、下地が見えないように少しカット範囲を狭くするべきか、あるいは、カット範囲を狭くしてステッカーが浮くと剥がれやすくなってしまうため、下地が見えてでもカット範囲を広くするべきか、その2択です。

まだ結論が出ていませんが、僕の好みは、下地が見えないようカット範囲を狭くするほうですね。


また今回はマット系の紙を選びましたが、やはり光沢系の見栄えが良いでしょうし、またアケコンは手が滑らないほうが良いようなので、その点でも光沢系のほうが良かったように思います。


という感じで、初めての発注にしてはなかなか上手くできたと思いますが、上に書いたようなこと微調整して、また発注しようと思います。

もし意見とかフィードバックとか、またカットパス入りのPSDファイルが欲しい人がいらっしゃれば、ツイッター( @cero_t )かなにかで連絡をください。


それでは、

Enjoy, your custom arcade controller!!


※その後、印刷した絵がかすれてしまいました。詳しくは次のブログエントリーで。

http://d.hatena.ne.jp/cero-t/20180318/1521344648

2017-12-15

[]ReactorでN+1問題な処理を実装してみた話

最近、格ゲーツイートが増えてる @ です。前のエントリーに書いた「18年ぶりに出る続編」のβテストがついに始まりまして、最近は夜な夜なコンボをやるなどしています。

シビアな反応が要求される格闘ゲームにおいて、継続して勝ち続けるためにはどうしても反射神経が必要となり、機械のような反射神経、つまり「反応装置」にならなくてはいけません、そうだから今日のテーマは「Reactor」なのです、、、みたいな流れを考えたんですが、どうにも苦しいですよね。ろくにスベることもできない中ですが、Javaアドベントカレンダー15日目が始まりました。

https://qiita.com/advent-calendar/2017/java


さて、Project ReactorはReactiveなノンブロッキング処理を書くためのライブラリです。最近はSpring 5.0などでも全面的に利用されているため話題になりがちです。今日はこれを勉強しながら、業務にありそうなケースを実装してみます。

なお、同期とか非同期とかブロッキングとかノンブロッキングとかReactiveとかの言葉の定義は、この際、置いておきます。実装を見て、雰囲気で掴んでください。


目次

0. はじめに

1. ノンブロッキング処理を体感する

2. Reactorらしいコードを書く

3. ノンブロッキングなら、シングルスレッドでも早いのか?

4. ブロッキング処理の場合はどうなる?

5. ブロッキング処理も、マルチスレッドで高速化


0〜2までは、Reactor初心者が苦慮してコードを書いていった話。

2〜5は、ノンブロッキング処理やブロッキング処理を、それぞれシングルスレッド、マルチスレッドで試してみた話です。

長いエントリーなので、興味がある部分を拾い読みしてもらえればと思います。


0. はじめに

まずは事前の準備や、目的などを説明します。


事前にやること

Reactorについて完全に素人だったので、まずは入門のスライドを読んでから、ハンズオン(チュートリアル)をやりました。


Spring 5に備えるリアクティブプログラミング入門

https://www.slideshare.net/TakuyaIwatsuka/spring-5


Reactive Webアプリケーション - そしてSpring 5へ #jjug_ccc #ccc_ef3

https://www.slideshare.net/makingx/reactive-web-spring-5-jjugccc-cccef3


Lite Rx API Hands-on(チュートリアル

https://github.com/reactor/lite-rx-api-hands-on/


なんとなく、Java5世代の非同期脳で、MonoがFutureのようなもの、FluxがList版のFutureのようなもの、という理解をしました。実際にはJava8で追加されたCompletableFutureのようなもののようですが、それはおいとくとして。

とりあえずこれらでReactorの概要をざっくり掴んだのですが、とにかく機能が多すぎて、全く覚えきれません。Stream APIをもう一度学びなおしてるような気持ちです。


今回の目的

今回は、いわゆる「N+1問題」をReactorで実装するとどうなるか、という検証をしてみます。

テーブルAから一覧データをN件持ってきて、次にそれに関連するデータをテーブルBから持ってくるという処理において、クエリがN+1回発生してしまうせいで遅い、というアレです。SQLを工夫すればクエリ1回で済むやろっていうコメントは、本題からズレるので★1です!


今回は「生徒一覧」を取得したうえで、生徒の「成績一覧」を検索することを想定します。

また検索対象は、RDBMSのようなブロッキング処理しかできないデータソースの場合と、何かしらイイ感じのノンブロッキングなデータソースの場合とを、それぞれ想定して比較します。


コードで言うと、次のようなイメージです。

生徒一覧の取得
Flux<Student> fetchStudents(String className)

点数一覧の取得
Flux<Score> fetchScores(int year, int id)

取り出したい形
Map<Student, List<Score>>

生徒の一覧を取り、それぞれの生徒の(2017年の)成績を取り、それをMono/FluxではなくMapやListの形にして返すというものです。

なぜわざわざMapに変換するんだ、なぜここでブロックしてしまうんだ、というツッコミを受けそうですが、あくまでも「全ての結果が揃ってから返す」けど、中の処理を並列にすることで、レスポンスを早くしたいというケースを想定しました。課題設定に口出し無用。


1. ノンブロッキング処理を体感する

まずは手探りでノンブロッキングなコードを書く所までをやります。

この章で紹介するソースコードは次のURLにあります。

https://github.com/cero-t/reactor-example/blob/master/src/main/java/ninja/cero/example/reactor/ReactorExample1.java


配列Fluxにする

まずは生徒一覧取得メソッドの実装として、生徒の配列からFluxを作って返します。

モックなので固定値を返してしまうのですが、検索を模しているので、1データ取得するのに100ミリ秒掛かるという想定にしました。こんな感じです。

Flux<Student> fetchStudents(String className) {
    Student[] students = {
            new Student(1, "Muto"),
            new Student(2, "Miyoshi"),
            new Student(3, "Matsui"),
            // 略
            new Student(28, "Mori"),
            new Student(29, "Tanaka"),
            new Student(30, "Yagi"),
    };

    return Flux.interval(Duration.ofMillis(100))
            .map(i -> students[i.intValue()])
            .take(students.length);
}

intervalメソッドで100ミリ秒おきに、mapメソッド配列から生徒を取り出して、takeメソッド配列分だけ生徒を取得したら終える、という実装です。

30人いるので、シーケンシャルに行えば、3000ミリ秒、つまり3秒掛かる処理ですね。


点数の一覧を取得するところも、実装の内容はほぼ同じです。

Flux<Score> fetchScores(int year, int id) {
    final Score[] scores = {
            new Score(id, "国語", 80),
            new Score(id, "数学", 90),
            new Score(id, "英語", 85),
            new Score(id, "社会", 93),
            new Score(id, "理科", 72)
    };

    return Flux.interval(Duration.ofMillis(100))
            .map(i -> scores[i.intValue()])
            .take(scores.length);
}

これもシーケンシャルに行えば、5科目 * 100ミリ秒で、500ミリ秒掛かる処理です。30人分あるので、15秒掛かる計算になります。

つまり生徒一覧と成績一覧の取得処理をすべてシーケンシャルに行うと、18秒掛かることになります。それをもっとうまく並列に処理したいというのが今回のテーマです。


生徒の結果が返ってき次第、成績を取得する

ここまでに書いた2つのメソッドを使って、生徒の一覧を取得して、それぞれの生徒ごとに成績の一覧を取得し、それをMapに変換するような処理を書いてみます。

Map<Student, List<Score>> map = new HashMap<>();
fetchStudents("sg")
        .subscribe(student -> {
            fetchScores(2017, student.id)
                    .subscribe(score -> map.computeIfAbsent(student, s -> new ArrayList<>()).add(score));
        });

System.out.println(map);

Fluxのsubscribeメソッドを使えば、値が1件戻ってくるたびにその値を使った処理を行うことができます。

そのメソッドを使って、

えーっと、、、先にMapのインスタンスを作っておいて、

subscribeの中でMapに追加していく、と、いう・・・、

完全に「素人ですかって」怒られるタイプのコードですよね、これ。いや素人ですから💢

あと、勘が良い方がは気づかれたかも知れませんが、ConcurrentModificationExceptionが発生しかねなかったりもしますね。悪い例がすぎますね。


ちなみにこのコードを実行すると、ほとんど結果が表示されずに終わってしまいます。mainスレッドが終了するからでしょうね。

であれば、その後にスリープしてしまえばいいんです。

try {
    Thread.sleep(2000L);
} catch (InterruptedException e) {
    e.printStackTrace();
}

いや素人にも程があるやろ💢💢 ってレベルのコードができあがりました。


これで実行させてみると・・・

{
{17 Kurosawa=[17 国語 80, 17 数学 90]
, 8 Sato=[8 国語 80, 8 数学 90, 8 英語 85, 8 社会 93, 8 理科 72]
, 11 Notsu=[11 国語 80, 11 数学 90, 11 英語 85, 11 社会 93, 11 理科 72]
, 1 Muto=[1 国語 80, 1 数学 90, 1 英語 85, 1 社会 93, 1 理科 72]
, 13 Ooga=[13 国語 80, 13 数学 90, 13 英語 85, 13 社会 93, 13 理科 72]
, 4 Nakamoto=[4 国語 80, 4 数学 90, 4 英語 85, 4 社会 93, 4 理科 72]
, 3 Matsui=[3 国語 80, 3 数学 90, 3 英語 85, 3 社会 93, 3 理科 72]
, 12 Taguchi=[12 国語 80, 12 数学 90, 12 英語 85, 12 社会 93, 12 理科 72]
, 16 Isono=[16 国語 80, 16 数学 90, 16 英語 85]
, 10 Kikuchi=[10 国語 80, 10 数学 90, 10 英語 85, 10 社会 93, 10 理科 72]
, 5 Iida=[5 国語 80, 5 数学 90, 5 英語 85, 5 社会 93, 5 理科 72]
, 18 Kurashima=[18 国語 80]
, 2 Miyoshi=[2 国語 80, 2 数学 90, 2 英語 85, 2 社会 93, 2 理科 72]
, 15 Shiroi=[15 国語 80, 15 数学 90, 15 英語 85, 15 社会 93]
, 6 Horiuchi=[6 国語 80, 6 数学 90, 6 英語 85, 6 社会 93, 6 理科 72]
, 14 Sugimoto=[14 国語 80, 14 数学 90, 14 英語 85, 14 社会 93, 14 理科 72]
, 7 Sugisaki=[7 国語 80, 7 数学 90, 7 英語 85, 7 社会 93, 7 理科 72]
, 9 Mizuno=[9 国語 80, 9 数学 90, 9 英語 85, 9 社会 93, 9 理科 72]
}

あぁー、どうあれ、なんか期待通りのものが取れてるじゃないですか! 勝利!!


と思ってよく見たら、18人分しか取れていません。僕的にはNakamoto, Kikuchi, Mizunoが取れていれば良いのですが。

また、15人目〜18人目の生徒は途中までしか成績が取れていません。なるほど非同期で処理してる途中でSystem.out.printlnが呼ばれて、処理が終わってしまったわけですね。


であれば、2000Lとしたsleep時間を10000Lぐらいにすれば解決しますよね!

・・・みたいな話を続けてるといい加減怒られそうなので、そろそろ真面目にやりましょう。


ログを出そう

真面目にやる前に、少し脇道に逸れて、ログの話をします。

ReactorのFluxMonoは、どこで何が起きているか分かりにくいのを少しでも解消するためか、随所に埋め込めるlogメソッドが用意されています。


このlogメソッドを使えばコンソールに最低限の情報を出ます。ただもう少し詳しい情報が欲しくなるため、ログフォーマットを指定したlogback.xmlを用意しておきます。

<configuration>

  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
    </encoder>
  </appender>

  <root level="debug">
    <appender-ref ref="STDOUT" />
  </root>
</configuration>

もちろんpom.xmlにもlogback-classicのdependencyを追加しておいてください。

        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.2.3</version>
        </dependency>

あとは見るべき所にログを埋め込んでいきます。今回はfetchStudentsメソッドと、fetchScoresメソッドの2箇所に入れておきます。

Map<Student, List<Score>> map = new HashMap<>();
fetchStudents("sg")
        .log("fetchStudents")
        .subscribe(student -> {
            fetchScores(2017, student.id)
                    .log("fetchScores")
                    .subscribe(score -> map.computeIfAbsent(student, s -> new ArrayList<>()).add(score));
        });

細かな挙動を把握したければもう少し違う箇所にもログを埋め込めば良いのですが、ログが多すぎても見通しが悪くなるため、この2箇所だけにしています。


これで実行して取れたログを見てみます。 # で始まっている部分は僕がつけたコメントです。

# まずは生徒取得処理の、onSubscribeとrequestが呼ばれる。
16:00:09.593 [main] INFO  fetchStudents - onSubscribe(FluxTake.TakeSubscriber)
16:00:09.601 [main] INFO  fetchStudents - request(unbounded)

# 生徒取得処理のonNextが呼ばれて、1人目のMutoさんの値が取得できる。
16:00:09.710 [parallel-1] INFO  fetchStudents - onNext(1 Muto)

# Mutoさんの成績取得処理のためにonSubscribeとrequestが呼ばれる。
16:00:09.712 [parallel-1] INFO  fetchScores - onSubscribe(FluxTake.TakeSubscriber)
16:00:09.712 [parallel-1] INFO  fetchScores - request(unbounded)

# 2人目のMiyoshiさんの値が取得でき、成績の取得が始まる。
16:00:09.804 [parallel-1] INFO  fetchStudents - onNext(2 Miyoshi)
16:00:09.805 [parallel-1] INFO  fetchScores - onSubscribe(FluxTake.TakeSubscriber)
16:00:09.805 [parallel-1] INFO  fetchScores - request(unbounded)

# 1人目のMutoさんの国語の成績が取れる。これは、先ほどまでとは別のスレッドで行われる。
16:00:09.816 [parallel-2] INFO  fetchScores - onNext(1 国語 80)

# 3人目のMatsuiさんの値が取得でき、成績の取得が始まる。
16:00:09.906 [parallel-1] INFO  fetchStudents - onNext(3 Matsui)
16:00:09.906 [parallel-1] INFO  fetchScores - onSubscribe(FluxTake.TakeSubscriber)
16:00:09.906 [parallel-1] INFO  fetchScores - request(unbounded)

# 2人目のMiyoshiさんの国語の成績がまた別スレッドで取得できる。
16:00:09.907 [parallel-3] INFO  fetchScores - onNext(2 国語 80)

# 1人目のMutoさんの数学の成績が、国語が取れた時と同じスレッドで取得できる。
16:00:09.918 [parallel-2] INFO  fetchScores - onNext(1 数学 90)

# 4人目のNakamotoさんの値が取得でき、成績の取得が始まる。
16:00:10.006 [parallel-1] INFO  fetchStudents - onNext(4 Nakamoto)
16:00:10.006 [parallel-1] INFO  fetchScores - onSubscribe(FluxTake.TakeSubscriber)
16:00:10.006 [parallel-1] INFO  fetchScores - request(unbounded)

ここまでの流れを確認すると、次のような流れになります。


1. まずmainスレッドで、生徒取得(fetchStudents)のsubscribe登録を行う

2. 生徒の値が返ってくると、parallel-1スレッドでsubscribeの中に書いた処理が行われる。

3. parallel-1スレッドで、成績取得(fetchScores)のsubscribe登録を行う

4. 成績の値が返ってくると、parallel-2からparallel-4までのスレッドでsubscribeの中に書いた処理が行われる。


ソースコードはとてもダメでしたが、処理自体はおおむね期待通りになっていることが分かりました。

ノンブロッキングかどうかみたいな話は、また後で詳しくやります。


ちなみに最後の方はこうなっていました。

16:00:11.509 [parallel-1] INFO  fetchScores - onNext(16 英語 85)
16:00:11.511 [parallel-3] INFO  fetchScores - onNext(18 国語 80)
16:00:11.512 [parallel-4] INFO  fetchScores - onNext(15 社会 93)
16:00:11.512 [parallel-3] INFO  fetchScores - onNext(14 理科 72)
16:00:11.512 [parallel-3] INFO  fetchScores - onComplete()
16:00:11.514 [parallel-2] INFO  fetchScores - onNext(17 数学 90)
16:00:11.605 [parallel-1] INFO  fetchStudents - onNext(20 Yamaide)
16:00:11.606 [parallel-1] INFO  fetchScores - onSubscribe(FluxTake.TakeSubscriber)
{18 Kurashima=[18 国語 80], 16 Isono=[16 国語 80, 16 数学 90, 16 英語 85], # (以降、省略)
16:00:11.606 [parallel-1] INFO  fetchScores - request(unbounded)

20人目のYamaideさんの処理を始めたところで、2000ミリ秒のsleepが終わって結果が出力された、という感じですね。


2. Reactorらしいコードを書く

上のようなコードを書いたあと、どうするのが正解なのかよく分からないなという気持ちになり @ さんに質問をしたところ、コードを添削して諸々教えてくれました。それが今回このエントリーを書くきっかけにもなったのです。


Reactorらしい修正をしたソースコードは次のURLにあります。

https://github.com/cero-t/reactor-example/blob/master/src/main/java/ninja/cero/example/reactor/ReactorExample2.java


subscribeではなくflatMapを使うが良い

Stream APIにおいて、foreachでListやMapに値を追加していくのが悪手であることは皆さんご存知だと思いますが、それはReactorのAPIでも変わりありません。subscribeの中で外部の変数に作用すると、処理の見通しが悪くなります。


今回の目的を実現するコードは、次のように修正できます。

Mono<Map<Student, List<Score>>> monoMap = fetchStudents("sg")
        .flatMap(student -> fetchScores(2017, student.id)
                .collectList()
                .map(scores -> Tuples.of(student, scores))
        )
        .collectMap(Tuple2::getT1, Tuple2::getT2);

Map<Student, List<Score>> map = monoMap.block();
System.out.println(map);

subscribeではなくflatMapを使って結果を変換します。fetchScoresで取得した Flux<Student> をcollectListで Mono<List<Student>> にして、それを一度Tuple2にするという形です。

いやこれ、自分じゃ思いつかない流れですが・・・。


その後、collectMapでTuple2からMapを作り、blockを使って待ち受けて、Monoではない通常のMapを取得しました。

これを実行すると、きちんと30人分の成績を取得することができました。


配列からFluxを作る別の方法

ところで、配列からFluxを作るところも、map/takeを使うのではなく、次のような形で書く方法を教えてもらいました。

return Flux.interval(Duration.ofMillis(100))
        .zipWith(Flux.fromArray(students))
        .map(Tuple2::getT2);

こんな書き方、チュートリアルにはなかったよ!

まぁでも、なるほどですね。


ログも見てみよう

この書き方ではどのような順で処理が行われるのか、ログを見て確認をします。

上のコードに、ログ出力部分を加えます。

Mono<Map<Student, List<Score>>> monoMap = fetchStudents("sg")
        .log("fetchStudents")
        .flatMap(student -> fetchScores(2017, student.id)
                .log("fetchScores")
                .collectList()
                .map(scores -> Tuples.of(student, scores))
        )
        .collectMap(Tuple2::getT1, Tuple2::getT2);

ログを入れるのは、やはりこの2箇所です。


出力されたログは次の通りです。

# まずは生徒取得処理の、onSubscribeとrequestが呼ばれる。
15:35:16.944 [main] INFO  fetchStudents - onSubscribe(FluxMap.MapSubscriber)
15:35:16.947 [main] INFO  fetchStudents - request(256)

# 1人目のMutoさんの処理
15:35:17.073 [parallel-1] INFO  fetchStudents - onNext(1 Muto)
15:35:17.083 [parallel-1] INFO  fetchScores - onSubscribe(FluxMap.MapSubscriber)
15:35:17.084 [parallel-1] INFO  fetchScores - request(unbounded)

# 2人目のMiyoshiさんの処理
15:35:17.170 [parallel-1] INFO  fetchStudents - onNext(2 Miyoshi)
15:35:17.170 [parallel-1] INFO  fetchScores - onSubscribe(FluxMap.MapSubscriber)
15:35:17.170 [parallel-1] INFO  fetchScores - request(unbounded)

# ここで1人目のMutoさんの国語の成績が返ってきた
15:35:17.187 [parallel-2] INFO  fetchScores - onNext(1 国語 80)

# 3人目のMatsuiさんの処理
15:35:17.270 [parallel-1] INFO  fetchStudents - onNext(3 Matsui)
15:35:17.270 [parallel-1] INFO  fetchScores - onSubscribe(FluxMap.MapSubscriber)
15:35:17.270 [parallel-1] INFO  fetchScores - request(unbounded)

# 次は2人目のMiyoshiさんの国語の成績と、1人目のMutoさんの数学の成績が返ってきた
15:35:17.273 [parallel-3] INFO  fetchScores - onNext(2 国語 80)
15:35:17.285 [parallel-2] INFO  fetchScores - onNext(1 数学 90)

# 4人目のNakamotoさんの処理
15:35:17.368 [parallel-1] INFO  fetchStudents - onNext(4 Nakamoto)
15:35:17.369 [parallel-1] INFO  fetchScores - onSubscribe(FluxMap.MapSubscriber)
15:35:17.370 [parallel-1] INFO  fetchScores - request(unbounded)

# 2人目のMiyoshiさんの数学と、3人目のMatsuiさんの国語が返ってきた
15:35:17.374 [parallel-3] INFO  fetchScores - onNext(2 数学 90)
15:35:17.374 [parallel-4] INFO  fetchScores - onNext(3 国語 80)

流れ的には元のものと同じですね。


またログの最後の部分を見ると、きちんと30人分の成績を受信しきってから、表示をしていました。

15:35:20.174 [parallel-1] INFO  fetchScores - onNext(28 社会 93)
15:35:20.174 [parallel-4] INFO  fetchScores - onComplete()
15:35:20.270 [parallel-3] INFO  fetchScores - onNext(30 英語 85)
15:35:20.270 [parallel-2] INFO  fetchScores - onNext(29 社会 93)
15:35:20.276 [parallel-1] INFO  fetchScores - onNext(28 理科 72)
15:35:20.276 [parallel-1] INFO  fetchScores - onComplete()
15:35:20.371 [parallel-2] INFO  fetchScores - onNext(29 理科 72)
15:35:20.371 [parallel-3] INFO  fetchScores - onNext(30 社会 93)
15:35:20.371 [parallel-2] INFO  fetchScores - onComplete()
15:35:20.471 [parallel-3] INFO  fetchScores - onNext(30 理科 72)
15:35:20.471 [parallel-3] INFO  fetchScores - onComplete()
{22 Shintani=[22 国語 80, 22 数学 90, 22 英語 85, 22 社会 93, 22 理科 72], # (以降、省略)

また、ログのタイムスタンプから、およそ3秒半ほどですべての処理が終わっていることが分かります。つまり、シーケンシャルに行えば18秒掛かる処理を、うまく並行させて3秒半で終わらせているのです。

この3秒半というのは、30人分の情報を取得するのに掛かる3秒 + 最後の1人の成績を取りきるのに掛かる0.5秒と、よく一致しています。

ノンブロッキングな感じで、いいじゃないですか。


3. ノンブロッキングなら、シングルスレッドでも早いのか?

ここまでの処理を見て、ノンブロッキングと非同期(マルチスレッド)の違いがよく分からなくなった方もいるかも知れません。

上で「ノンブロッキングな感じ」と書きましたが、果たしてそのおかげで早かったのか、マルチスレッドの恩恵で早くなったのか、分かりにくいところがあります。


では、シングルスレッドにしてみればどうなるでしょうか。見てみましょう。

シングルスレッドにしたソースコードは次のURLから取得できます。

https://github.com/cero-t/reactor-example/blob/master/src/main/java/ninja/cero/example/reactor/ReactorExample3.java


publishOnでスレッドを制御

処理をシングルスレッドにする場合は、Fluxを生成している所でpublishOnメソッドを用いて、スレッドの作成ポリシーを決めます。ここではSchedulers.single() を用いてシングルスレッドで実行することにします。

return Flux.interval(Duration.ofMillis(100))
        .publishOn(Schedulers.single())
        .zipWith(Flux.fromArray(students))
        .map(Tuple2::getT2);

publishOn(Schedulers.single()) の処理を挟んだだけですね。

これをfetchStudents、fetshScoresのそれぞれで行います。


実行してみた

それではシングルスレッドになっているか、実行してログを確認してみましょう。

16:21:49.073 [main] INFO  fetchStudents - onSubscribe(FluxMap.MapSubscriber)
16:21:49.080 [main] INFO  fetchStudents - request(256)
16:21:49.197 [single-1] INFO  fetchStudents - onNext(1 Muto)
16:21:49.202 [single-1] INFO  fetchScores - onSubscribe(FluxMap.MapSubscriber)
16:21:49.202 [single-1] INFO  fetchScores - request(unbounded)
16:21:49.296 [single-1] INFO  fetchStudents - onNext(2 Miyoshi)
# (省略)
16:21:52.501 [single-1] INFO  fetchScores - onNext(29 理科 72)
16:21:52.501 [single-1] INFO  fetchScores - onComplete()
16:21:52.501 [single-1] INFO  fetchScores - onNext(30 社会 93)
16:21:52.603 [single-1] INFO  fetchScores - onNext(30 理科 72)
16:21:52.604 [single-1] INFO  fetchScores - onComplete()
{6 Horiuchi=[6 国語 80, 6 数学 90, 6 英語 85, 6 社会 93, 6 理科 72], # (以降、省略)

一部抜粋ですが、すべての処理が「single-1」スレッドで行われていました。


シングルスレッドで行われていたにも関わらず、処理は3.5秒程度で完了しています。これで「100ミリ秒待つ」というのがノンブロッキングで行われていることが、なんとなく実感できました。


4. ブロッキング処理の場合はどうなる?

さて、ここまでは「ノンブロッキング処理は早くていいね、18秒掛かる処理が3.5秒になったよ」みたいな話でした。

しかし、もしRDBMSのような、ブロッキング処理のあるデータソースから情報を取得しなくてはいけなくなった場合は、どうなるのでしょうか。それを模したコードを書いて検証してみます。


ブロッキング処理にしたソースコードは、ここにあります。

https://github.com/cero-t/reactor-example/blob/master/src/main/java/ninja/cero/example/reactor/ReactorExample4.java


Thread.sleepはブロッキング処理

ブロッキング処理を行うためには、Fluxを作成する処理の中で、Thread.sleepを行うのが良いです。

生徒や成績のFluxを行う処理を、次のように変更します。

Flux<Student> studentFlux = Flux.create(sink -> {
    for (Student student : students) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        sink.next(student);
    }
    sink.complete();
});
return studentFlux;

Flux.createメソッドで、sinkに値を入れていれていく形です。ここでThread.sleepを使うことで(先程までのFlux.intervalと異なり)このFluxの作成処理がブロッキング処理となるんです。ほぅ。


実行してログで確認

これまでの流れ通り、実行してログを確認してみましょう。

# mainスレッドでfetchStudentsの呼び出し
16:38:37.457 [main] INFO  fetchStudents - onSubscribe(FluxCreate.BufferAsyncSink)
16:38:37.461 [main] INFO  fetchStudents - request(256)

# 1人目のMutoさんの処理をmainメソッドで行い、成績取得もすべてmainメソッド
16:38:37.570 [main] INFO  fetchStudents - onNext(1 Muto)
16:38:37.574 [main] INFO  fetchScores - onSubscribe(FluxCreate.BufferAsyncSink)
16:38:37.575 [main] INFO  fetchScores - request(unbounded)
16:38:37.677 [main] INFO  fetchScores - onNext(1 国語 80)
16:38:37.781 [main] INFO  fetchScores - onNext(1 数学 90)
16:38:37.882 [main] INFO  fetchScores - onNext(1 英語 85)
16:38:37.983 [main] INFO  fetchScores - onNext(1 社会 93)
16:38:38.085 [main] INFO  fetchScores - onNext(1 理科 72)
16:38:38.087 [main] INFO  fetchScores - onComplete()
16:38:38.087 [main] INFO  fetchStudents - request(1)

# 2人目のMiyoshiさんの処理もとにかくmainメソッド
16:38:38.193 [main] INFO  fetchStudents - onNext(2 Miyoshi)
16:38:38.195 [main] INFO  fetchScores - onSubscribe(FluxCreate.BufferAsyncSink)
16:38:38.195 [main] INFO  fetchScores - request(unbounded)
16:38:38.296 [main] INFO  fetchScores - onNext(2 国語 80)
16:38:38.401 [main] INFO  fetchScores - onNext(2 数学 90)
16:38:38.503 [main] INFO  fetchScores - onNext(2 英語 85)
16:38:38.606 [main] INFO  fetchScores - onNext(2 社会 93)
16:38:38.709 [main] INFO  fetchScores - onNext(2 理科 72)
16:38:38.709 [main] INFO  fetchScores - onComplete()
16:38:38.709 [main] INFO  fetchStudents - request(1)

# 3人目以降も同様
16:38:38.812 [main] INFO  fetchStudents - onNext(3 Matsui)
# (省略)
16:38:54.864 [main] INFO  fetchStudents - request(1)
16:38:54.967 [main] INFO  fetchStudents - onNext(29 Tanaka)
16:38:54.968 [main] INFO  fetchScores - onSubscribe(FluxCreate.BufferAsyncSink)
16:38:54.968 [main] INFO  fetchScores - request(unbounded)
16:38:55.072 [main] INFO  fetchScores - onNext(29 国語 80)
16:38:55.176 [main] INFO  fetchScores - onNext(29 数学 90)
16:38:55.281 [main] INFO  fetchScores - onNext(29 英語 85)
16:38:55.386 [main] INFO  fetchScores - onNext(29 社会 93)
16:38:55.488 [main] INFO  fetchScores - onNext(29 理科 72)
16:38:55.488 [main] INFO  fetchScores - onComplete()
16:38:55.488 [main] INFO  fetchStudents - request(1)
16:38:55.592 [main] INFO  fetchStudents - onNext(30 Yagi)
16:38:55.592 [main] INFO  fetchScores - onSubscribe(FluxCreate.BufferAsyncSink)
16:38:55.592 [main] INFO  fetchScores - request(unbounded)
16:38:55.697 [main] INFO  fetchScores - onNext(30 国語 80)
16:38:55.798 [main] INFO  fetchScores - onNext(30 数学 90)
16:38:55.902 [main] INFO  fetchScores - onNext(30 英語 85)
16:38:56.004 [main] INFO  fetchScores - onNext(30 社会 93)
16:38:56.110 [main] INFO  fetchScores - onNext(30 理科 72)
16:38:56.110 [main] INFO  fetchScores - onComplete()
16:38:56.110 [main] INFO  fetchStudents - request(1)
16:38:56.110 [main] INFO  fetchStudents - onComplete()
{5 Iida=[5 国語 80, 5 数学 90, 5 英語 85, 5 社会 93, 5 理科 72], # (以降、省略)

すべての処理がmainスレッドで行われており、時間も18.5秒掛かっています。これはすべての処理がシーケンシャルに行われれば18秒である、という計算と一致します。

いかにReactorを使っていようとも、途中にRDBMSへのJDBCドライバー経由でのアクセスなど、ブロッキング処理が入るとこのようになってしまうのです。


5. ブロッキング処理も、マルチスレッドで高速化

ブロッキング処理を使うと、Reactorを使う意味がないのでしょうか、というとそういうわけでもありません。

Reactorを使って、ブロッキング処理であっても、マルチスレッド処理を書くことができます。


ブロッキング処理をマルチスレッド化したソースコードは、次の場所にあります。

https://github.com/cero-t/reactor-example/blob/master/src/main/java/ninja/cero/example/reactor/ReactorExample5.java


subscribeOnでスレッドを制御

先の章ではFluxを生成する際にpublishOnメソッドを用いてスレッドの制御をしましたが、それと同様に、subscibeする側でsubscribeOnメソッドを用いることでも、スレッドを制御することができるようになります。シングルスレッドで生成されたものを、マルチスレッドで分担して処理するという形になります。


次のように、fetchStudents、fetchScoresの直後にsubscribeOnを渡します。ここで渡しているSchedulers.elastic()は、必要なだけスレッドを起こすというものです。

Mono<Map<Student, List<Score>>> monoMap = fetchStudents("sg")
        .subscribeOn(Schedulers.elastic())
        .log("fetchStudents")
        .flatMap(student -> fetchScores(2017, student.id)
                .subscribeOn(Schedulers.elastic())
                .log("fetchScores")
                .collectList()
                .map(scores -> Tuples.of(student, scores))
        )
        .collectMap(Tuple2::getT1, Tuple2::getT2);

Map<Student, List<Score>> map = monoMap.block();
System.out.println(map);

これだけでマルチスレッド化ができます。元の流れと大きく変わっていないところがポイントですね。


それでは、実行してログを見てみましょう。

17:35:03.769 [main] INFO  fetchStudents - onSubscribe(FluxSubscribeOn.SubscribeOnSubscriber)
17:35:03.773 [main] INFO  fetchStudents - request(256)
17:35:03.883 [elastic-2] INFO  fetchStudents - onNext(1 Muto)
17:35:03.897 [elastic-2] INFO  fetchScores - onSubscribe(FluxSubscribeOn.SubscribeOnSubscriber)
17:35:03.898 [elastic-2] INFO  fetchScores - request(unbounded)
17:35:04.001 [elastic-2] INFO  fetchStudents - onNext(2 Miyoshi)
17:35:04.001 [elastic-3] INFO  fetchScores - onNext(1 国語 80)
17:35:04.001 [elastic-2] INFO  fetchScores - onSubscribe(FluxSubscribeOn.SubscribeOnSubscriber)
17:35:04.001 [elastic-2] INFO  fetchScores - request(unbounded)
17:35:04.103 [elastic-3] INFO  fetchScores - onNext(1 数学 90)
17:35:04.103 [elastic-2] INFO  fetchStudents - onNext(3 Matsui)
17:35:04.103 [elastic-2] INFO  fetchScores - onSubscribe(FluxSubscribeOn.SubscribeOnSubscriber)
17:35:04.103 [elastic-2] INFO  fetchScores - request(unbounded)
17:35:04.103 [elastic-4] INFO  fetchScores - onNext(2 国語 80)
17:35:04.204 [elastic-3] INFO  fetchScores - onNext(1 英語 85)
17:35:04.204 [elastic-4] INFO  fetchScores - onNext(2 数学 90)
17:35:04.204 [elastic-2] INFO  fetchStudents - onNext(4 Nakamoto)
17:35:04.205 [elastic-5] INFO  fetchScores - onNext(3 国語 80)
# (省略)
17:35:07.131 [elastic-5] INFO  fetchScores - onComplete()
17:35:07.136 [elastic-6] INFO  fetchScores - onNext(28 社会 93)
17:35:07.136 [elastic-7] INFO  fetchScores - onNext(29 英語 85)
17:35:07.137 [elastic-8] INFO  fetchScores - onNext(30 数学 90)
17:35:07.241 [elastic-7] INFO  fetchScores - onNext(29 社会 93)
17:35:07.241 [elastic-6] INFO  fetchScores - onNext(28 理科 72)
17:35:07.241 [elastic-8] INFO  fetchScores - onNext(30 英語 85)
17:35:07.242 [elastic-6] INFO  fetchScores - onComplete()
17:35:07.344 [elastic-8] INFO  fetchScores - onNext(30 社会 93)
17:35:07.344 [elastic-7] INFO  fetchScores - onNext(29 理科 72)
17:35:07.345 [elastic-7] INFO  fetchScores - onComplete()
17:35:07.449 [elastic-8] INFO  fetchScores - onNext(30 理科 72)
17:35:07.450 [elastic-8] INFO  fetchScores - onComplete()
{30 Yagi=[30 国語 80, 30 数学 90, 30 英語 85, 30 社会 93, 30 理科 72], # (以降、省略)

詳細な説明は割愛しますが、ノンブロッキングで行っていたときと同じような処理の流れとなりました。処理は3.7秒で、ノンブロッキングのときと大差はありません。また、このログで見えているだけでも「elastic-8」まであり、8スレッド使っていることが分かります。


elastic vs parallel

上の例では8スレッド使っていましたが、Fluxを生成する際のsleep時間などを少し調整すると、優に数十スレッドを使ってしまいました。これはこれで、スレッドを使いすぎる問題が起きかねません。


もう少し加減してスレッドを使って欲しい場合には、Schedulers.elastic()ではなく、Schedulers.parallel()を使います。こちらはスレッド数をCPU数分までに制限します(ただし最低は4)

Mono<Map<Student, List<Score>>> monoMap = fetchStudents("sg")
        .subscribeOn(Schedulers.parallel())
        .log("fetchStudents")
        .flatMap(student -> fetchScores(2017, student.id)
                .subscribeOn(Schedulers.parallel())
                .log("fetchScores")
                .collectList()
                .map(scores -> Tuples.of(student, scores))
        )
        .collectMap(Tuple2::getT1, Tuple2::getT2);

Map<Student, List<Score>> map = monoMap.block();
System.out.println(map);

これを実行した結果、こうなりました。

17:40:42.309 [main] INFO  fetchStudents - onSubscribe(FluxSubscribeOn.SubscribeOnSubscriber)
17:40:42.312 [main] INFO  fetchStudents - request(256)
17:40:42.427 [parallel-1] INFO  fetchStudents - onNext(1 Muto)
17:40:42.438 [parallel-1] INFO  fetchScores - onSubscribe(FluxSubscribeOn.SubscribeOnSubscriber)
17:40:42.438 [parallel-1] INFO  fetchScores - request(unbounded)
17:40:42.542 [parallel-1] INFO  fetchStudents - onNext(2 Miyoshi)
17:40:42.542 [parallel-2] INFO  fetchScores - onNext(1 国語 80)
17:40:42.542 [parallel-1] INFO  fetchScores - onSubscribe(FluxSubscribeOn.SubscribeOnSubscriber)
17:40:42.542 [parallel-1] INFO  fetchScores - request(unbounded)
17:40:42.646 [parallel-2] INFO  fetchScores - onNext(1 数学 90)
17:40:42.646 [parallel-3] INFO  fetchScores - onNext(2 国語 80)
17:40:42.646 [parallel-1] INFO  fetchStudents - onNext(3 Matsui)
17:40:42.648 [parallel-1] INFO  fetchScores - onSubscribe(FluxSubscribeOn.SubscribeOnSubscriber)
17:40:42.648 [parallel-1] INFO  fetchScores - request(unbounded)
17:40:42.751 [parallel-2] INFO  fetchScores - onNext(1 英語 85)
17:40:42.751 [parallel-3] INFO  fetchScores - onNext(2 数学 90)
17:40:42.751 [parallel-1] INFO  fetchStudents - onNext(4 Nakamoto)
17:40:42.751 [parallel-4] INFO  fetchScores - onNext(3 国語 80)
# (省略)
17:40:47.023 [parallel-1] INFO  fetchScores - onComplete()
17:40:47.125 [parallel-1] INFO  fetchScores - onNext(16 国語 80)
17:40:47.228 [parallel-1] INFO  fetchScores - onNext(16 数学 90)
17:40:47.333 [parallel-1] INFO  fetchScores - onNext(16 英語 85)
17:40:47.437 [parallel-1] INFO  fetchScores - onNext(16 社会 93)
17:40:47.542 [parallel-1] INFO  fetchScores - onNext(16 理科 72)
17:40:47.543 [parallel-1] INFO  fetchScores - onComplete()
17:40:47.648 [parallel-1] INFO  fetchScores - onNext(20 国語 80)
17:40:47.750 [parallel-1] INFO  fetchScores - onNext(20 数学 90)
17:40:47.853 [parallel-1] INFO  fetchScores - onNext(20 英語 85)
17:40:47.955 [parallel-1] INFO  fetchScores - onNext(20 社会 93)
17:40:48.060 [parallel-1] INFO  fetchScores - onNext(20 理科 72)
17:40:48.060 [parallel-1] INFO  fetchScores - onComplete()
17:40:48.163 [parallel-1] INFO  fetchScores - onNext(24 国語 80)
17:40:48.264 [parallel-1] INFO  fetchScores - onNext(24 数学 90)
17:40:48.365 [parallel-1] INFO  fetchScores - onNext(24 英語 85)
17:40:48.468 [parallel-1] INFO  fetchScores - onNext(24 社会 93)
17:40:48.569 [parallel-1] INFO  fetchScores - onNext(24 理科 72)
17:40:48.569 [parallel-1] INFO  fetchScores - onComplete()
17:40:48.671 [parallel-1] INFO  fetchScores - onNext(28 国語 80)
17:40:48.775 [parallel-1] INFO  fetchScores - onNext(28 数学 90)
17:40:48.879 [parallel-1] INFO  fetchScores - onNext(28 英語 85)
17:40:48.985 [parallel-1] INFO  fetchScores - onNext(28 社会 93)
17:40:49.087 [parallel-1] INFO  fetchScores - onNext(28 理科 72)
17:40:49.087 [parallel-1] INFO  fetchScores - onComplete()
{9 Mizuno=[9 国語 80, 9 数学 90, 9 英語 85, 9 社会 93, 9 理科 72], 1 # (以降、省略)

スレッド名が parallel-1 から parallel-4 までの4スレッドになりました。4スレッド使ってできる範囲で処理を分担し、最後は残った処理を1スレッドで片付けている、という形です。処理全体には7秒弱掛かっており、スレッド生成し放題のelasticに比べれば2倍近く時間が掛かっていますが、単一スレッドで行うよりもレスポンスは半分以下に短縮されています。


もちろんこの処理はReactorを使わずともJava標準のExecutorServiceなり何なりを使っても同じことができます。ただ、ノンブロッキングな処理も扱えるReactorと、統一した書き方ができるところがメリットの一つになると思います。いや、Reactorでブロッキング処理を扱うようなものを書けること自体、おまけみたいなものかも知れませんが。


ちなみにelasticやparallel以外にも、ExecutorServiceを渡すなどすることもできますが、shutdownのタイミングなど考えると管理が面倒なので、普通にelasticかparallelを使っておくのが良いと私は思っています。


まとめ

Reactorを使って、ノンブロッキング処理、ブロッキング処理を、それぞれどのように扱うかを確認しました。


特にデータアクセスや、マイクロサービス呼び出しのような待ち時間の多いシチュエーションにおいて、うまくノンブロッキング処理にできれば、レスポンスタイムを短縮できるんじゃないかなと思います。

もちろん、その分データソース側に負荷が掛かるため、データソース側が十分にスケールアウトできることが前提となりますが、現代であればそういう環境は手に入りやすいため、取り組む価値があると見ています。


今回のケースがReactorの最適なユースケースだというわけではないですが、業務において、このような使い方もできるという一例として、参考にしてもらえばと思います!

Enjoy, Reactor!

2017-12-07

[][]Goで格闘ゲームのマクロを実装してみた

このところ、夜な夜な格ゲーの動画を撮ってはYoutubeにアップしては、SRK(shoryuken.com)に取り上げられて承認欲求を満たしている @ です。SRKは世界最大の格ゲーコミュニティだよ!

http://shoryuken.com/?s=Shin+Tanimoto


このエントリーは Go2 Advent Calendar 2017 の7日目です。

https://qiita.com/advent-calendar/2017/go2


はじめに

まえがき

学生時代、僕は格闘ゲームの連続技(コンボ)の研究を嗜んでおりまして、学校に行くぐらいならゲーセンを行く、試験勉強するぐらいならコンボの研究をする、卒論を書くぐらいならコンボビデオを撮るという、クズみたいな学生生活を送っておりました。当時、いまは亡き「ゲーメスト」というアーケードゲーム雑誌で体験ライターとして編集部に泊まり込んで記事を書くような体験もしました。

その頃よくプレイしていたストリートファイター系のゲームが、なんと18年の歳月を経て新作を出すというのです。これは万難を排してやらねばならぬと相成ったのですが、当時20歳前後だった僕も先日ついに40歳を迎えたわけで、さすがにもうあの頃のようには手が動きません。

しかし、いまの僕には、あの頃の自分にはなかったプログラミング能力があります。人間が手作業で行っていることを、機械に行わせるのが仕事です。そう、格闘ゲームも機械にやらせてしまえば良いじゃないか、というのが今回のテーマになります。


※なお、ゲームのネット対戦やオンラインランキングでマクロを使うのはマナー違反です。決してやらないでください。


目的

プレステ3やプレステ4、Xboxなどで使えるマクロを作ります。実装にはGoを使いました。


用意するもの

ゲーム機とPCをつなぐ、何かしらのデバイスが必要になります。今回はConsole Tuner社のTitan Oneという機器を利用します。

https://www.consoletuner.com/products/titan-one/

f:id:cero-t:20171207075818j:image:w360


これはUSBゲームコントローラーを様々なゲーム機で使えるようにするための変換器です。しかしただの変換機能だけでなく、PCと繋ぐことでマクロ機能やプログラミングを組んだりできるほか、API自体もDLLとして公開されているので、自作アプリからも利用することができます。今回の用途にはベストです。


Titan OneとDLLを使って、次のような流れで操作を流し込みます。

ゲーム機 <-(USB)- Titan One <-(USB)- Windows(DLL <- 自作アプリケーション)

なんとなくイメージが湧くでしょうか?


なぜアプリを自作するのか?

先ほど「マクロ機能やプログラミング環境が用意されている」と書いた通り、用意された環境を利用すれば、別にアプリを自作するまでもなくマクロを実行することができます。ただ、付属のプログラミング環境ではミリ秒単位の制御しかできず、格闘ゲームの60fps(framer per second)の制御をするには不便でした。格闘ゲーマーは物事をフレーム単位で考えていて、ミリ秒単位で考えるわけではないのです。

なので、フレーム単位で扱いやすくするための、簡単なアプリを自作することにしました。


なぜGoなのか?

今回のアプリは、配布が容易であることと、Mac/Windowsのいずれでも開発できるという点でGoを選びました。

僕はJavaエンジニアなので、最初はJavaでJNAを使って実装しました。Javaでも問題なく動くものができたのですが、ただちょっと配布するのが面倒だなと思い、かと言ってC#にするとMacでの開発環境が微妙なので、Goを選ぶに至りました。もちろんそんな消去法的な選択だけでなく、ちょっとGoを勉強してみたかったという背景もあります。


DLL呼び出しの実装

それではGoで実装していきます。僕はGo初心者で体系的に学んだこともないので、ここがおかしいよとか、ここもっとこうした方が良いよというのがあれば、教えてもらえると凄く嬉しいです。


DLLの読み込み

まずTitan Oneが提供しているDLL(gcdapi.dll)を読み込む部分を作ります。

goでDLLを読み込むにはsyscall.LoadDLLを使います。

dll, _err := syscall.LoadDLL("gcdapi.dll")
if _err != nil {
	log.Fatal("Error loading gcdapi.dll", _err)
}

これでDLLの読み込みができます。


ここで「Failed to load gcdapi.dll: %1 is not a valid Win32 application.」というエラーに悩まされました。

原因は「gcdapi.dllが32bit」だったことです。64bit環境でコンパイル/実行しようとするとエラーになるので、32bit環境向けにコンパイルする必要があります。

set GOARCH=386

これでOK。

ここで1時間ほどハマっていました。


DLL内の関数呼び出し

DLLにある関数を呼び出すには、読み込んだdllのFindProc関数を使います。

gcdapi_Load, _err := dll.FindProc("gcdapi_Load")
if _err != nil {
	log.Fatal("cannot find gcdapi_Load", _err)
}

result, _, _err := gcdapi_Load.Call()
if result == 0 {
	log.Fatal("gcdapi cannot be loaded")
}

これでgcdapi_Loadという関数を実行できます。

DLL内にある他の関数も、同じ要領で先に読み込んでおきます。


また、関数に引数を渡す必要がある場合は、uintptr型のポインタとして渡します。

次の例は、someFunction関数にarg1という値を渡す例です。

result, _, _ := someFunction.Call(uintptr(arg1))

戻り値はresultに入ります。


配列や構造体を引数に渡す

引数に配列や構造体を渡す場合は、先に参照をポインタに変換してから、さらにuintptrに変換します。

正直、参照がどうなってるのかもはや僕には理解できないのですが、こうすれば動くということだけ確認しました。

func write(inputs *[36]int8) bool {
	result, _, _ := procWrite.Call(uintptr(unsafe.Pointer(inputs)))
	// 略
	return true
}

ちなみにここで配列ではなくスライスを渡していたために上手く動かず、2時間ほどハマりました。初学者はとにかくハマって時間をロストしますね。


また構造体を引数にする場合は、DLL側に用意された構造体と同じ名前、同じ型の構造体をGo側に用意します。

次の例ではGCAPI_REPORTという構造体を引数に渡しています。

type GCAPI_REPORT struct {
	console       uint8
	controller    uint8
	led           [4]uint8
	rumble        [2]uint8
	battery_level uint8
	input         [30]GCAPI_INPUT
}

type GCAPI_INPUT struct {
	value      int8
	prev_value int8
	press_tv   uint32
}

func read() bool {
	var report GCAPI_REPORT
	result, _, _ := procRead.Call(uintptr(unsafe.Pointer(&report)))
	log.Println(report)
	// 略
	retun true
}

構造体は恐ろしいぐらいにハマらずに値が入りました。簡単です。最高です。


MacでもWindowsでも実行したい

ちなみにここまで使ってきたsyscall.LoadDLLや、その戻り値であるsyscall.DLL、syscall.ProcなどはWindows環境でしか利用できず、Mac環境でコンパイルしようとするとエラーになってしまいます。

せめてMacではDLL呼び出し部分だけモックにして、アプリ全体としては動くようにしておきたいものです。そういうのもきちんと用意されていました。


同じパッケージ内に、同名の関数を定義した別ソースファイル(モック用関数群)を作り、そのファイルの先頭にこんなコメントを入れます。

// +build !windows

これでWindowsの環境以外でビルドした場合には、モック用関数群のファイルが利用されるようになります。

・・・ちょっと何を言ってるのか分からない感じなので、実物を見て頂いたほうが早いと思います。


Windows用のソースコードがこれ。

https://github.com/cero-t/cero-macro.t1/blob/master/gcapi/gcapi_dll_windows.go


Windows以外用のソースコードはこれです。

https://github.com/cero-t/cero-macro.t1/blob/master/gcapi/gcapi_dll_other.go


いいですね、簡単です。


次に入力側を作る

DLLの呼び出しが上手く行ったので、次は呼び出し側の設計/実装です。


懐かしのコマンド

20世紀に格闘ゲームをやっていた人にとって、「236p」という文字列は波動拳コマンドにしか見えません。これはテンキー配列の「下 右下 右」に相当するもので、最後のpがパンチです。当時のパソコン通信やインターネットで頻繁に用いられた記法です。同様に、昇龍拳は「623p」、竜巻旋風脚は「214k」となります。今回はこの表記をします。

また、パンチの小中大はそれぞれl, m, hで表現し、たとえば小パンチは「lp」、中キックは「mk」、しゃがみ大キックは「2,hk」とします。これは海外の格闘ゲーマーが用いている表記方法です。


この表記法を使い、たとえば次のようなテキストを受け取ることを想定します。

2,mk 8
3 1
6,lp 1

これはレバー下と中キックを8フレーム、右下に1フレーム、右と小パンチを1フレーム、というコマンドです。中足波動拳ですね。

このようなコマンドを受け取って、上で説明した関数の呼び出しを行うようにします。


入力コマンドの変換

格闘ゲームの入力は精度が命ですから、Go側の処理に時間が掛かってコマンドが不安定になってしまっては意味がありません。そのため、上に書いたテキストをあらかじめパースして配列などに変換しておき、その後にまとめてDLL経由でコマンドを流し込む処理を行うようにします。変換とDLL呼び出しを逐次行っていると、想定しない処理遅延が発生する可能性があるためです。


テキストをパースした後、次のような構造体に変換します。

type State struct {
	buttons [36]int8
	frames  uint16
}

buttonsが押すべきボタンの一覧です。配列のそれぞれがボタンやレバーに対応しており、押さない時は0、押した時は100という値が入ります。


変換は地味なコードなので割愛します。ここでやってます。

https://github.com/cero-t/cero-macro.t1/blob/master/processor/converter.go


正確な60fpsのコマンド実行

続いて、コマンド実行の処理です。State構造体の配列をきっちりと指定したフレームで呼び出す必要があります。

ちなみに格闘ゲームではよく「1/60秒」という表現が使われて、「光速は遅すぎる」など話題になることがありますが、少なくとも手元環境のプレステ3で確認したところ、1フレームは「1/60秒」ではなく「1/59.94秒」でした。


そのあたりも踏まえて、次のようなコードを書きました。ボタンを押した後に、そのフレーム数分(たとえば5フレームなら、約 5 * 16.6ms = 83ms)だけ待つというコードです。

ただしこの処理自体の実行時間も考慮する必要があるため、現在時刻など利用して、フレームレートを保つようにしています。

// フレームレート (frame per 1000 seconds)
const frameRate uint64 = 59940

func Process(states *[]State) {
	var totalFrames uint64
	start := time.Now().UnixNano()

	for _, state := range *states {
		// ボタンを押す
		gcapi.Push(&state.buttons)

		// フレーム数分だけsleepする
		totalFrames += uint64(state.frames)
		sleepTime := start + int64(totalFrames*1000*1000*1000*1000/frameRate) - time.Now().UnixNano()
		time.Sleep(time.Duration(sleepTime))
	}
}

正確に59.94fpsを保つためにわざわざアプリを自作したので、この処理が一番のキモと言えますね。


この関数を含む処理全体はここにあります。

https://github.com/cero-t/cero-macro.t1/blob/master/processor/processor.go


UIはどうする?

最後にUIです。

GoのUIをどうすべきかは議論があるようですが、「クロスプラットフォームで使えて、皆が慣れ親しんでいるUI」と言えば、コマンドライン(CUI)とHTMLの2つでしょう。


まずはCUI

テキストファイルを読み込んでその中に書かれた操作コマンドを実行するCUIを作ります。

こんな感じで、実行時の引数にファイル名を指定します。

$ go run macro.go combo.txt

これでcombo.txtに書かれた内容が、ゲーム機に送られるのです。


このmacro.txtという渋い名前のファイルの中身は、こんな感じです。

9 34
hp 20
2,mk 8
6 1
2 1
3,hp 1

ジャンプ大パンチ 中足 昇龍拳、という感じです。


続いてGUI

もう一つのUIが、HTTPサーバとして起動し、画面で入力されたコマンドを実行するGUIです。実行時の引数にファイル名を指定しなければHTTPサーバとして立ち上がります。

$ go run macro.go
Server started at [::]:8080

それでlocalhostの(あるいは別のPCからIPアドレスを指定して)8080ポートにアクセスすれば、こんな画面が出てきます。

f:id:cero-t:20171207075959p:image

テキストボックスにコマンドを入力して「run」を押せば実行できる形です。

マクロで動かす以外にも簡単な操作をリモートでできるよう、他のボタンも置いておきました。「LP」ボタンを押すと、「lp 2」というマクロ(小パンチを2フレーム押す)が送られます。


なおHTMLファイルの使い方ですが、Goでリソースをバンドルするのがちょっと面倒だったので、ソースコード内にヒアドキュメントで書いてしまいました。

func formHTML() string {
	return `
<!DOCTYPE html>
<html>
<head>
(略)
</body>
</html>
`
}

なおViewフレームワークには超軽量で有名な「Vanilla JS」を用いているため、この1ファイルだけでViewのレンダリングやサーバとの通信処理なども実装できています。

http://vanilla-js.com/

はい。


HTTPクライアント、サーバ関連のソース全体はこちらです。

https://github.com/cero-t/cero-macro.t1/blob/master/http/form-html.go

https://github.com/cero-t/cero-macro.t1/blob/master/http/http.go


実際、試してみたところ・・・!!

これで画面からDLLまですべて繋がったので、いざ実機で挑戦!


おぉ、動く動く! キャラが動くよ!


・・・あれ、コンボが途切れるぞ?

なんか、コンボが不安定だぞ?


そうなんです、どうやらTitan Oneは処理遅延が10ms程度あり、その遅延も安定しないため、格闘ゲームの1フレームを安定させて動かすのが難しいようなのです。

ここまで書いてきてこのオチはなかなか酷いのですが、別にこれで終わりというわけではありません。

より遅延が少ないデバイスが出てきて、それをAPI経由で叩けるようになれば、また再挑戦したいと思います。


あれ、この手元にあるデバイスは、何だろう??

f:id:cero-t:20171207080038j:image:w360


来年には、API経由で叩けるようになるのかなぁ。

2017-03-20

TT-BA09で、テレビの音声をBluetoothで無線化してみた(失敗編)

成功の影に失敗あり。

テレビの音声をBluetoothでワイヤレス化しようという試みですが、一度失敗していたので、今回はその話を書きます。

成功した話は、前回のエントリーを読んでください。

http://d.hatena.ne.jp/cero-t/20170111/1484142249


テレビをBluetooth化するには「トランスミッター」と「レシーバー」が必要というのは前回説明した通りですが、それぞれ最初に選んだ製品は、トランスミッター側が前回も書いた「TT-BA09」で、レシーバー側はオーディオテクニカの「AT-PHA50BT」でした。

AT-PHA50BTは「ワイヤレスヘッドホンアンプ」とうたわれていて簡易アンプ機能がついているため、音量調整ができるだけでなくエフェクト機能などもついているので良さそうと思い、選びました。

これを、次のように接続しました。


テレビ → (光音声出力) → TT-BA09 → (Bluetooth/apt-X) → AT-PHA50BT → (ケーブル) → ヘッドホン


まぁトランスミッターとレシーバーを繋ぐときは、普通にこうなります。前回書いた「TT-BA08」のところがAT-PHA50BTになっただけです。


なんかおかしいぞ?

それで接続して使い始めたのですが、問題が2つありました

  1. 切断/再接続をすると、音が途切れ途切れになって使えなくなる
  2. 音量が変えられない

まず切断/再接続時の挙動ですが、接続中に一度AT-PHA50BT側(レシーバー側)の電源を入れ直すと、音が途切れ途切れになってしまい、実質的に使えなくなります。TT-BA09側(トランスミッター側)の電源を入れ直せば問題なくなりますし、まぁBluetooth機器だし、これぐらいの相性問題はあるよね、という認識で我慢して使おうかと思いました。


それより困るのが「音量を変えられない」という問題。AT-PHA50BT(レシーバー)のダイヤルをいくら回しても、ヘッドホンから聞こえる音量は全く変わらないのです。ヘッドホンアンプの役割を果たしてくれません。

もちろん、テレビ側の音量を変えても(光音声出力経由ですから)ヘッドホン側の音量は変わりません。ライブ音源なのに音が小さすぎたりすると、かなりガッカリです。


初期不良かな?

そう思って、試しにAT-PHA50BTをiPhoneとBluetooth接続してみたのですが、音量もエフェクトも含めて、すべての機能がきちんと動きます。再接続しても問題ありません。


それで気づいたんですが、、、


あれ?

TT-BA09と繋ぎ直すと、さっきより音が大きくなってるぞ?


そうなんです、iPhoneで音量を変えた後に、改めてTT-BA09とAT-PHA50BTを接続し直すと、iPhoneで変えた時の音量のままになってるんです。いったい何を言ってるか分からねー(略)

要するにAT-PHA50BTは内部的に音量を保持していて、iPhone(などのスマホ)と接続した時には音量を変更できるけど、TT-BA09と接続した時には変更ができないと、そういう感じのようです。


なので、TT-BA09と繋いでいる時に音が大きい/小さいなーと思ったら、iPhoneと繋ぎ直して音量を調整して、またTT-BA09に繋ぎ直せばいいだけだ、ということです😇

ってできるわけないやろ💢


サポートへの問い合わせ

我慢しながら使おうかな、とも思ったのですが、一縷の望みを掛けて、サポートに問い合わせることにしました。

僕はトラブルシューターですから、こういう不具合報告はお手の物です。問題の状況や、いくつかの組み合わせで検証した結果などを、きちんと事実と推測に分けて報告をしました。もちろん、他社製品のせいだと言われないような予防線も張ります。こういう報告を送られると、逃げようがないですからね。


問い合わせを出して数日後、返信がありました。

返信内容は転載禁止なので詳しくは書けませんが、「スマートフォンと繋ぐことを前提にしたので、電話帳機能がない機器と繋ぐとボリュームとか効かなくなることがあります」みたいな事が書かれていました。


そんなこと、製品紹介のどこにも書かれてないやないか!!!💢💢

https://www.audio-technica.co.jp/atj/show_model.php?modelId=2661


・・・なんて怒りを抑えつつ、せめてと思い、「別に交換しろとは言わないから、せめてホームページに書いとけや!」に社会性フィルタを掛けた内容でメールを返信しておきました。すぐに「指摘を真摯に受け止めます」という旨の大人語な回答を頂きました。

2ヶ月以上前の話ですが、いまだ書かれている様子はないので、今後も変わらないでしょうね。


どうしようこれ

一時は我慢しながら使おうかと思ったAT-PHA50BTですが、このサポートとのやり取りも含めて諦めるしかないと判断し、前回の記事に書いたとおりTT-BA08を買い直したわけです。TT-BA08との組み合わせなら、何ら問題ありません。


と言うことで使わなくなったAT-PHA50BTですが、これ、何に使いましょうね。

iPhoneやMacとはきちんと接続できるので、勉強会とかで「音を鳴らしたいけど、ケーブルが届かない!」という時に、サッと出してヒーローになる。そんな夢を見ながら、カバンに忍ばせておくことにしましょうかね。

2017-01-11

TT-BA09で、テレビの音声をBluetoothで無線化してみた(成功編)

BABYMETALのライブが放送される時期だけWOWOWを契約する、ちょっとWOWOWにとっては迷惑気味なメイトのCERO-METALデス!


さて、普段ヘッドホンで音楽を聴くようになってから、テレビでもライブなどを観る時にはテレビのスピーカーでは物足りなくなり、ヘッドホンで聴くようになりました。ただ3mぐらいのケーブルを使ってもテレビまで近いですし、キッチンで料理や洗い物をしながらテレビを観る時になんかには使えません。

そんな背景から、テレビの音声をワイヤレスにしてヘッドホンで聴きたいなーと思っていました。


ちょうどWOWOWでBABYMETALの東京ドームライブが放送される年末年始のこのタイミングで、テレビの音声をBluetoothで飛ばして、ヘッドホンで聴ける環境を作ることにしました。


何がいるの?

そもそも、テレビの音声をBluetoothで飛ばすには何が必要か、という整理からです。接続の流れはこんな感じになります。


テレビ → Bluetoothトランスミッター → Blueotoothレシーバー → ヘッドホン


テレビからの音声入力を受けてBluetoothで飛ばすものを「トランスミッター」と呼び、Bluetoothで受信をする方を「レシーバー」と呼びます。ITエンジニアの皆様方にも分かりやすく説明すると、トランスミッターがWi-Fiルーターに相当し、レシーバーがWi-Fi子機に相当します。

またレシーバーとヘッドホンをケーブルで繋ぐものもあれば、ヘッドホン自体がレシーバーになるワイヤレスヘッドホンなどもあります。


製品の選び方

Bluetoothのトランスミッターやレシーバーを選ぶ際の観点になるのが、次の2つです。

  • 音声入力の方式(ヘッドホンジャック、赤と白のRCAピンプラグ、光デジタルなど)
  • 対応コーデック(SBC、AAC、apt-X、LDACなど)

音声入力の方式はまぁ分かるので割愛するとして、よく分からないのがコーデックです。

ざっくり調べた感じ、次のような違いがあるようです。

  • SBC: ほとんどの機器が対応するけど、音質が悪い。
  • AAC: 主にApple製品で使われる。SBCより音質が良い。
  • apt-X: Androidや多くの機器で使われる。AACより遅延が少ない。
  • apt-X HD: apt-Xをさらに高音質にしたもの。まだ対応製品が少ない。
  • LDAC: ハイレゾ音源に対応するもの。音質は一番良いがソニー製品でしか採用されていない。

対応製品が多いapt-Xが、現在ではほぼスタンダードになってるようです。apt-Xに対応したトランスミッターはいくつかあるのですが、AACやLDACに対応した単体のトランスミッターというのは、ちょっと見つけられませんでした。


実際に買った製品

それで今回用意したのは、次の製品です。

  • TaoTronics TT-BA09(トランスミッターとして利用)
  • TaoTronics TT-BA08(レシーバーとして利用)

この組み合わせで、ばっちりワイヤレスで視聴できる環境が整いました。


TT-BA09

音声のトランスミッター、レシーバーの両方に使える製品です。TaoTronics社からは似たような製品がいくつか出ているのですが、このTT-BA09は光音声入力があったので、これを選びました。ちなみにTT-BA09には音量を調整する機能がありません。


TT-BA08

上の製品と同じく音声のトランスミッター、レシーバーの両方に使える製品です。TT-BA09よりも一回り小さな製品です。

こちらはレシーバーとして使う際に音量の調整ができるようになっています。通常、光音声入力では出力側での音量調整ができず、レシーバーで音量調整する必要があるため、これを選びました。


それで、繋いでみると。

さすがは同じメーカーの同一シリーズの製品でしょうか、全く何の問題もなく接続できました。

接続はこんな流れです。


テレビ → (光音声出力) → TT-BA09 → (Bluetooth/apt-X) → TT-BA08 → (ケーブル) → ヘッドホン


Bluetoothを切断/再接続をしても、全く問題なく音声を聞くことができます。

また、テレビの音量を変えてもヘッドホンから聞こえる音量は変わらないため、テレビは消音にし、音量をTT-BA08で調整するような使い方になります。音量を調整できないレシーバーだと、こういう事ができないんですよね。


遅延とか音質は?

Bluetoothでよく言われるのが音声の遅延。

apt-Xでは40msほどの遅延があると言われており、口と声がズレるのを気にする僕としては、遅延があるのははっきり分かります。遅延があまり分からない人でも、テレビとヘッドホンの音を両方出せば、はっきりとズレていることは分かるでしょう。

ただ、そうは言っても40msですから、ズレを検出しようとしない限りは、さほど気にならない程度でした。


そして気になる音質ですが、これはとても良いです。

ショボいテレビのスピーカーに比べれば音声はクリアに聞こえ、特に海外映画などの英語もだいぶ聞き取りやすくなりました。音楽番組などもしっかり低音が聞こえるのは、さすがヘッドホンですね。

音楽番組を流しっぱなしにしながらリビングやキッチンでウロウロしても、ヘッドホンで聴けるのは最高です。


・・・ということで、特にトラブルも起きることないつまらない話になったのですが、実はこの前に、別メーカーの製品との組み合わせでトラブルがありました。せっかくなので、それについては、また改めて書きたいと思います。


See you!