Ruby(Windows)で改行付き文字列データを扱う場合はテキストモード(バイナリモード)に気をつけろ!!

はじまり

Rubyに限った話ではないのかもしれないですが、Rubyで改行を含むテキストデータを送受信するようなプログラムをWindowsで書いていた時に思った通りに処理されなくて嵌ってしまったので、自戒を込めてブログに残しておきます。

結論

Windowsで CR/LF をそのまま受け渡したいのであれば、IO#binmodeを設定しよう!!

詳細

例えば以下のようなプログラムがあったとします。

write.rb

$stdout.write "1\r2\n3\r\n"
$stdout.flush

read.rb

while char = $stdin.getc
  p char
end

以下のようにコマンドプロンプトから実行してみます。

> ruby write.rb | ruby read.rb

出力は以下のようになります。

"1"
"\n"
"2"
"\n"
"3"
"\n"
"\n"

\r がすべて \n に変換されてしまっています。 これは IO#getc がそのような変換をかけるために発生します。これに対応するには2種類の方法があります。一つは IO#read を length 1 で呼び出すようにコードを置き換える方法、もう一つは IO#binmode を設定する方法です。 read.rb (IO#read使用)

while char = $stdin.read(1)
  p char
end

read.rb (IO#binmode使用)

$stdin.binmode
while char = $stdin.getc
  p char
end

IO#binmode は IOオブジェクトをバイナリモードに設定するためのメソッドになります。バイナリモードというのは Windows 特有のモードのようで、通常はテキストモードになっています。このモードを変更すると改行の処理が変化します。テキストモードの場合は puts "\n" のようなコードを実行すると \r\n が出力され、getc に対して \r\n が入力されると \n が入力されたものとして処理されます。バイナリモードにすることで、コードに記述した通りの出力となり、入力も \r\n を別々に処理されるようになります。Rubyのリファレンスマニュアルにも記載されています。

docs.ruby-lang.org

Windows の IO にはテキストモードとバイナリモードという2種類のモードが存在します。これらのモードは上で説明した IO のエンコーディングとは独立です。改行の変換にしか影響しません。

さて、上記のコードを実行した出力を見てみましょう。どちらも以下の通り同じ出力になります。

"1"
"\r"
"2"
"\r"
"\n"
"3"
"\r"
"\r"
"\n"

なにかおかしいですね。1 の後の \r がそのまま出力されているのはいいのですが、今度は \n\r\n と出力されています。これは先ほど説明したWindowsのIOモードの問題が出力側に残っているのが原因です。出力側に対策を入れていないので、出力側で $stdout.write "\n" というコードを実行すると、\n\r\n に変換して出力します。この問題に対応するためには 出力側にも IO#binmode を追加します。

write.rb (IO#binmode使用)

$stdout.binmode
$stdout.write "1\r2\n3\r\n"
$stdout.flush

出力は以下のようになります。

"1"
"\r"
"2"
"\n"
"3"
"\r"
"\n"

期待した出力になりました。めでたしめでたし。

注意点

今回は標準入出力にbinmodeを設定して対策しましたが、一度binmodeを設定すると、closeするまでバイナリモードのままとなります。標準入出力は再オープンできないので、一度binmodeを設定してしまうとそのプログラム内では二度とテキストモードに戻すことができませんので注意が必要です。

まとめ

  • 入出力をパイプでつなぐような処理を記載するとき改行が入るようなデータを扱う場合、かつ Windows が関係する場合は極力バイナリモードでデータの受け渡しは行うこと。
  • 出力側は IO#binmode を使用することでバイナリモードとなる
  • 入力側は IO#getc の代わりに IO#read(1) を使用するか、出力側と同様に IO#binmode を使用することでバイナリモードとなる

Hotwire チュートリアルをする

Hotwire とは

hotwired.dev

Railsの作者 DHHが提唱する新しいSPAの形を実装したライブラリになります。

今まではサーバーサイドのデータをJSONのようなフォーマットでクライアントに渡し、クライアント側のJavascriptでHTMLをレンダリングすることでSPAを実現するといった流れでしたが、HotwireはサーバーサイドでHTMLをレンダリングし、必要な部分だけをクライアント側で置き換えることで実現されます。

これによって、レンダリングに関わるコードが Ruby で記述できるので、Ruby好きにはうれしいライブラリとなるかと思います。

チュートリアル

なぜかドキュメントの Get Started 的なものは見つからず、 hotwired.dev のトップページにあるデモ動画が唯一のチュートリアルとなるようです。

www.youtube.com

ただ、8/13 現在、この通りにやってもうまくいかないのでその部分を交えつつチュートリアルをやっていこうと思います。

Ruby バージョン

ruby 3.0.2p107 (2021-07-07 revision 0db68f0233) [x86_64-linux]

※ 今回のチュートリアルでは内部で Redis を使いますので、Redisのインストールを行ってからチュートリアルをスタートしてください。

Rails アプリの作成

$ rails new chat --skip-javascript
$ cd chat

Gemfile の編集

  • rails のバージョンを githubのマスターにします。( 現状は importmap-railsrails の最新に依存しているみたいだったので )
  • gem 'importmap-rails' を足します
  • gem 'hotwire-rails' を足します
gem 'rails', :github => 'rails/rails' # ← ここは編集
gem 'importmap-rails' # ← ここは追加
gem 'hotwire-rails' # ← ここは追加

各種 gem のインストール

$ bundle update
$ bundle install
$ rails importmap:install
$ rails hotwire:install

ベースのアプリを作成

$ rails g scaffold room name:string
$ rails g model message room:references content:text
$ rails db:migrate
$ bundle install

各種ファイルの編集・作成

config/routes.rb 【編集】

Rails.application.routes.draw do
  resources :rooms do
    resources :messages
  end
end

app/models/room.rb 【編集】

class Room < ApplicationRecord
    has_many :messages
end

app/controllers/messages_controller.rb 【作成】

class MessagesController < ApplicationController
  before_action :set_room, only: %i[ new create ]

  def new
    @message = @room.messages.new
  end

  def create
    @message = @room.messages.create!(message_params)

    redirect_to @room
  end

  private
    def set_room
      @room = Room.find(params[:room_id])
    end

    def message_params
      params.require(:message).permit(:content)
    end
end

app/views/messages/new.html.erb 【作成】

<h1>New Message</h1>

<%= turbo_frame_tag "new_message", target: "_top" do %>
    <%= form_with(model: [ @message.room, @message ]) do |form| %>
        <div class="field">
            <%= form.text_field :content %>
            <%= form.submit "Send" %>
        </div>
    <% end %>
<% end %>

<%= link_to 'Back', @message.room %>

app/views/messages/_message.html.erb 【作成】

<p id="<%= dom_id message %>">
  <%= message.created_at.to_s(:short) %>: <%= message.content %>
</p>

app/views/rooms/show.html.erb 【編集】

<p id="notice"><%= notice %></p>

<%= turbo_frame_tag "room" do %>
  <p>
    <strong>Name:</strong>
    <%= @room.name %>
  </p>
  
  <%= link_to 'Edit', edit_room_path(@room) %> |
  <%= link_to 'Back', rooms_path, "data-turbo-frame": "_top" %>
<% end %>

<div id="messages">
  <%= render @room.messages %>
</div>

<%= turbo_frame_tag "new_message", src: new_room_message_path(@room), target: "_top" %>

app/views/rooms/edit.html.erb 【編集】

<h1>Editing Room</h1>

<%= turbo_frame_tag "room" do %>
  <%= render 'form', room: @room %>
<% end %>

<%= link_to 'Show', @room %> |
<%= link_to 'Back', rooms_path %>

app/asserts/stylesheets/application.css

/*
 * This is a manifest file that'll be compiled into application.css, which will include all the files
 * listed below.
 *
 * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's
 * vendor/assets/stylesheets directory can be referenced here using a relative path.
 *
 * You're free to add application-wide styles to this file and they'll appear at the bottom of the
 * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
 * files in this directory. Styles in this file should be added after the last require_* statement.
 * It is generally better to create a new file per style scope.
 *
 *= require_tree .
 *= require_self
 */
turbo-frame {
    display: block;
    border: 1px solid blue;
}

※ ここまでで rails を実行すると、Roomを作成して編集するときに画面遷移なしに編集ボックスが出るようになり、メッセージも遷移なしに追加されます。

turbo_stream フォーマットの追加

app/controllers/messages_controller.rb 【編集】

class MessagesController < ApplicationController
  # ... 省略

  def create
    @message = @room.messages.create!(message_params)

    respond_to do |format|
        format.turbo_stream
        format.html { redirect_to @room }
    end
  end

  # ... 省略
end

app/views/messages/create.turbo_streaam.erb 【作成】

<%= turbo_stream.append "messages", @message %>

メッセージの相互通信の実現

app/assets/javascripts/controllers/reset_form_controller.js 【作成】

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  reset() {
    this.element.reset()
  }
}

app/views/messages/new.html.erb 【編集】

<h1>New Message</h1>

<%= turbo_frame_tag "new_message", target: "_top" do %>
    <%= form_with(model: [ @message.room, @message ],
            data: { controller: "reset_form", action: "turbo:submit-end->reset_form#reset"}) do |form| %>
        <div class="field">
            <%= form.text_field :content %>
            <%= form.submit "Send" %>
        </div>
    <% end %>
<% end %>

<%= link_to 'Back', @message.room %>

app/views/rooms/show.html.erb 【編集】

<p id="notice"><%= notice %></p>

<%= turbo_stream_from @room %>

<% #省略 %>

app/models/message.rb 【編集】

class Message < ApplicationRecord
  belongs_to :room
  broadcasts_to :room
end

app/views/messages/create.turbo_streaam.erb 【編集】

<% # Return handled by cable %>

ルーム名のリアルタイム同期の実装

app/models/room.rb 【編集】

class Room < ApplicationRecord
    has_many :messages
    broadcasts
end

app/views/rooms/show.html.erb 【編集】

<p id="notice"><%= notice %></p>

<%= turbo_stream_from @room %>

<%= turbo_frame_tag "room" do %>
  <%= render @room %>

<% #省略 %>  

app/views/rooms/_room.html.erb 【編集】

<p id="<%= dom_id room %>">
  <strong>Name:</strong>
  <%= room.name %>
</p>

参考資料

qiita.com

qiita.com

qiita.com

qiita.com

Microsoft 純正 Dependency Injection ライブラリ試してみた

V-VMDependency Injection でつなげる例

using System;
using System.Windows;
using MessagePipe;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;

namespace MessagePipeTest2
{
    public partial class App : Application
    {
        IHost host;

        private void Application_Startup(object sender, StartupEventArgs e)
        {
            host = CreateHostBuilder().Build();

            var vm = host.Services.GetRequiredService<MainWindowVM>();
            var w = new MainWindow();

            w.DataContext = vm;
            w.Closed += (sender, e) => host.Dispose();
            w.Show();

            host.RunAsync();
        }

        static IHostBuilder CreateHostBuilder()
        {
            return Host.CreateDefaultBuilder()
                .ConfigureServices((ctx, services) =>
                {
                    services.AddMessagePipe();
                    services.AddHostedService<Worker>();
                    services.AddSingleton<MainWindowVM>();
                });
        }
    }
}

AddSingleton で VM をサービスに登録。必要なタイミングで GetRequiredService でインスタンス化して V の DataContext に設定。

M の部分は AddHostedService でサービスに登録すると同時にインスタンス化している。

VM - M 感は MessagePipe の Pub-Sub で繋げてやり取りする。

↓↓↓↓↓↓↓ VM

using System;
using MessagePipe;
using Prism.Mvvm;

namespace MessagePipeTest2
{
    class MainWindowVM : BindableBase
    {
        private int _value;
        public int Value
        {
            get { return _value; }
            set { SetProperty(ref _value, value); }
        }

        private string _text;
        public string Text
        {
            get { return _text; }
            set { SetProperty(ref _text, value); }
        }

        ISubscriber<MyEvent> subscriber;
        readonly IDisposable disposable;

        public MainWindowVM(ISubscriber<MyEvent> subscriber)
        {
            this.subscriber = subscriber;

            var bag = DisposableBag.CreateBuilder();

            this.subscriber.Subscribe(Callback).AddTo(bag);

            disposable = bag.Build();
        }

        public void Callback(MyEvent e)
        {
            Value = e.Value;
            Text = e.Text;
        }

        void Close()
        {
            disposable.Dispose();
        }
    }
}

↓↓↓↓↓↓↓↓ M

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using MessagePipe;

namespace MessagePipeTest2
{
    class Worker : BackgroundService
    {
        IPublisher<MyEvent> publisher;

        MyEvent e;

        public Worker(IPublisher<MyEvent> publisher)
        {
            this.publisher = publisher;

            e = new MyEvent();
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            await Task.Run(() =>
            {
                while (!stoppingToken.IsCancellationRequested)
                {
                    e.Value++;
                    e.Text += DateTime.Now.ToString() + "\n";
                    publisher.Publish(e);
                }
            });
        }
    }
}

CySharp/MagicOnion を試してみた

CySharp/MagicOnion は以下のリポジトリにあります

github.com

基本的にはREADMEを読むか、リポジトリ直下にある sample を参考にすれば動作はわかると思います。

以下の記事は本家の人のブログ

tech.cygames.co.jp

試した結果はGitHubに置きました。

github.com

  • GrpcService2 はサーバーサイド
  • ConsoleApp1 はコンソールベースのクライアント
  • WpfApp1WPFベースのクライアント
  • ClassLibrary1 はインターフェースを定義しているライブラリ。サーバーとクライアントで共有している

感想

gRPCの使用感で簡単に使えるのはいいかなと思いました。 MessagePack を使用するので通信に乗せるクラスはすべて定義が必要なのがちょっとめんどくさい感じがありますが、いったん定義してしまえばだれでも使えるようになるので大規模開発には重要ですね。一斉同報的な処理とか簡単にできるあたりも好印象で、コネクション毎に処理が分断されている(?)みたいなので無駄に並列処理を意識せずにチャットアプリが簡単に作れるのがいい感じです。

Cysharp/MessagePipe を試してみた。

github.com

tech.cygames.co.jp

感想

ブログにも書いてある通り prism の eventAggregator より速いので、今後は prism の代わりにこっちを使ってもいいかなぁと思いました。

App.xaml

<Application x:Class="MessagePipeTest.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:MessagePipeTest"
             Startup="Application_Startup"
             >
    <Application.Resources>
         
    </Application.Resources>
</Application>

App.cs

using System;
using System.Windows;
using MessagePipe;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;

namespace MessagePipeTest2
{
    public partial class App : Application
    {
        IHost host;

        private void Application_Startup(object sender, StartupEventArgs e)
        {
            host = CreateHostBuilder().Build();

            var vm = host.Services.GetRequiredService<MainWindowVM>();
            var w = new MainWindow();

            w.DataContext = vm;
            w.Closed += (sender, e) => host.Dispose();
            w.Show();

            host.RunAsync();
        }

        static IHostBuilder CreateHostBuilder()
        {
            return Host.CreateDefaultBuilder()
                .ConfigureServices((ctx, services) =>
                {
                    services.AddMessagePipe();
                    services.AddHostedService<Worker.Worker>();
                    services.AddSingleton<MainWindowVM>();
                });
        }
    }
}

MainWIndow.xaml

<Window x:Class="MessagePipeTest2.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:MessagePipeTest2"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <StackPanel>
        <TextBlock Text="{Binding Value}" />
        <TextBox Background="Gray" TextWrapping="Wrap" VerticalScrollBarVisibility="Visible" Height="400"
                 Text="{Binding Text}"
                 TextChanged="tbox1_TextChanged"/>
    </StackPanel>
</Window>

MainWIndow.cs

using System.Windows;
using System.Windows.Controls;

namespace MessagePipeTest2
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private void tbox1_TextChanged(object sender, TextChangedEventArgs e)
        {
            ((TextBox)sender).ScrollToEnd();
        }
    }
}

MainWindowVM

using System;
using MessagePipe;
using Prism.Mvvm;

namespace MessagePipeTest2
{
    class MainWindowVM : BindableBase
    {
        private int _value;
        public int Value
        {
            get { return _value; }
            set { SetProperty(ref _value, value); }
        }

        private string _text;
        public string Text
        {
            get { return _text; }
            set { SetProperty(ref _text, value); }
        }

        ISubscriber<MyEvent> subscriber;
        readonly IDisposable disposable;

        public MainWindowVM(ISubscriber<MyEvent> subscriber)
        {
            this.subscriber = subscriber;

            var bag = DisposableBag.CreateBuilder();

            this.subscriber.Subscribe(x =>
            {
                Value = x.Value;
                Text = x.Text;
            }).AddTo(bag);

            disposable = bag.Build();
        }

        void Close()
        {
            disposable.Dispose();
        }
    }
}

MyEvent.cs

namespace MessagePipeTest2
{
    public class MyEvent
    {
        public int Value { get; set; }

        public string Text { get; set; }
    }
}

Worker.cs

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using MessagePipe;

namespace MessagePipeTest2
{
    class Worker : BackgroundService
    {
        IPublisher<MyEvent> publisher;

        public Worker(IPublisher<MyEvent> publisher)
        {
            this.publisher = publisher;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            int i = 0;
            string s = "";
            while (!stoppingToken.IsCancellationRequested)
            {
                publisher.Publish(new MyEvent()
                {
                    Value = i,
                    Text = s,
                });
                i++;
                s += DateTime.Now.ToString() + "\n";
                await Task.Delay(1, stoppingToken);
            }
        }
    }
}

Cysharp/ZString を試してみた

github.com

Cygames の子会社 Cy# さんが出してる C#OSSライブラリ。メモリ消費量が少なく、早い(?)

tech.cygames.co.jp

細かい内容は上の Cy# さんのブログに書かれています。

試したコード

using System;
using System.IO;
using Cysharp.Text;

namespace ZStringTestDotnetCore
{
    class Program
    {
        static void Main(string[] args)
        {
            var ts1 = MeasureTask(ZStringConcat);
            var ts2 = MeasureTask(MemoryStreamStringFormat);
            var ts3 = MeasureTask(ZStringFormatUtf8);
            var ts4 = MeasureTask(ZStringFormatUtf16);
            var ts5 = MeasureTask(MemoryStreamZStringFormat);

            Console.Write($"- ZString(Concat)   {ts1}\n");
            Console.Write($"- Memory Stream   1 {ts2}\n");
            Console.Write($"- ZString(Format) A {ts3}\n");
            Console.Write($"- ZString(Format) B {ts4}\n");
            Console.Write($"- Memory Stream   2 {ts5}\n");
        }

        static TimeSpan MeasureTask(Action func)
        {
            var sw = new System.Diagnostics.Stopwatch();

            sw.Reset();
            sw.Start();
            func();
            sw.Stop();
            return sw.Elapsed;
        }

        static void ZStringConcat()
        {
            int i = 0,j = 0,k = 0;
            using (var sb = ZString.CreateUtf8StringBuilder())
            {
                while (i < 300000)
                {
                    var now = DateTime.Now;
                    sb.Append(ZString.Concat("i:", i, ",j:", j , ",k:", k, "\n"));
                    i++;
                    j += 2;
                    k += 3;
                }
                sb.WriteToAsync(Console.OpenStandardOutput()).Wait();
            }
        }

        static void MemoryStreamStringFormat()
        {
            int i = 0,j = 0,k = 0;
            using(var ms = new MemoryStream())
            {
                using (var wr = new StreamWriter(ms))
                {
                    while (i < 300000)
                    {
                        var now = DateTime.Now;
                        wr.Write(string.Format("i:{0},j:{1},k:{2}\n", i, j, k));
                        i++;
                        j += 2;
                        k += 3;
                    }
                    wr.Flush();
                    ms.WriteTo(Console.OpenStandardOutput());
                }
            }
        }

        static void ZStringFormatUtf8()
        {
            int i = 0, j = 0, k = 0;
            using (var sb = ZString.CreateUtf8StringBuilder())
            {
                while (i < 300000)
                {
                    var now = DateTime.Now;
                    sb.Append(ZString.Format("i:{0},j:{1},k:{2}\n", i, j, k));
                    i++;
                    j += 2;
                    k += 3;
                }
                sb.WriteToAsync(Console.OpenStandardOutput()).Wait();
            }
        }


        static void ZStringFormatUtf16()
        {
            int i = 0, j = 0, k = 0;
            using (var sb = ZString.CreateStringBuilder())
            {
                while (i < 300000)
                {
                    var now = DateTime.Now;
                    sb.Append(ZString.Format("i:{0},j:{1},k:{2}\n", i, j, k));
                    i++;
                    j += 2;
                    k += 3;
                }
                Console.WriteLine(sb.ToString());
            }
        }

        static void MemoryStreamZStringFormat()
        {
            int i = 0, j = 0, k = 0;
            using (var ms = new MemoryStream())
            {
                using (var wr = new StreamWriter(ms))
                {
                    while (i < 300000)
                    {
                        var now = DateTime.Now;
                        wr.Write(ZString.Format("i:{0},j:{1},k:{2}\n", i, j, k));
                        i++;
                        j += 2;
                        k += 3;
                    }
                    wr.Flush();
                    ms.WriteTo(Console.OpenStandardOutput());
                }
            }
        }
    }
}

結果

- ZString(Concat)   00:00:02.9890532
- Memory Stream   1 00:00:02.6363530
- ZString(Format) A 00:00:02.6669643
- ZString(Format) B 00:00:03.3741640
- Memory Stream   2 00:00:02.6118988

私の試したコードではあまり早くなりませんでした。MemoryStream + StreamWriter のほうがちょっと早いかなという感じ。ここでは記載していませんが、メモリ消費量もあまり違いがない感じでした。もしかすると、ZString は Unity との親和性に重きを置いているので、もしかするとこういうコードではあまり恩恵は受けれないのかもです。

ただ、StringBuilder と比較すると WriteToAsync() メソッドがあって便利ですし、 UTF8用のBuilderもあるので、そちらも便利そうです。

Flutter に入門したい

あらすじ

Flutter がラズパイでも動作することを知り、何か IoT 的なものが作れないかを検討したいので、 Flutter に入門してみようと思う。

チュートリアル

Google CodeLabs の 「初めての Flutter アプリの作成 」 の パート1 と パート2 はとっかかりとしてはよさそうだったし、日本語だったのでそちらをやってみた。

codelabs.developers.google.com

codelabs.developers.google.com

時計の表示

現在時刻をリアルタイムで表示するにはどうしたらいいか。というのが気になったので、以下を参考に作成。

flutter.keicode.com

今後

medium.com

qiita.com

www.flutter-study.dev

adventar.org

このあたりを見ながら、見繕って学習していきたい。