最近、趣味のコーディングは Go を使ってやることが多かったりして、Goでなんでも書きたい欲が強くなるんだけども、やはりGUIアプリを作成するときにはWindowsならC#一択になる傾向があるのが、私の最近なのですが、C#で記述する部分をできるだけなくして、それ以外をGoで書けないかなぁと模索している中で、MVVMのM(Model)の部分をGoで記述して、V-VMの部分のみをWPFで記述し、間をプロセス間通信でどうにかすれば、WebアプリのJavascriptとバックエンドの関係のように記述できないかなぁ?と思ったりしています。
なので、今回はプロセス間通信にGRPCを使って、GUI部分をC#で、ロジック部分をGoでコーディングしたらどうなるか?という試験的なアプリを作ってみました。
アプリを雑なイメージにするとこんな感じです。
C#側をGRPCのクライアント、Go側をサーバーにします。アプリ側にはクライアントを2つ用意しておきます。GRPCの通信インターフェースには SayHello()
と StreamMessage()
があり、SayHello()で任意の文字列を送ると、送った文字列の頭にHello
を返して送り返してきます。StreamMessage()
はストリームで繋がっているインターフェースで、このインターフェースを使って、SayHello()
されたときの返信文字列を別のチャンネルにも流しています。例えば、C#側にはクライアントCH1とCH2があります。CH1のSayHello()を呼び出すと、CH2のStreamMessage()と経由して、CH1のメッセージが届きます。逆に、CH2のSayHello()を呼び出すと、CH1のStreamMessage()を経由してCH2のメッセージが届きます。
proto
ファイルは以下のようになっています。
// Copyright 2015 gRPC authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. syntax = "proto3"; option java_multiple_files = true; option java_package = "io.grpc.examples.helloworld"; option java_outer_classname = "HelloWorldProto"; option objc_class_prefix = "HLW"; package helloworld; // The greeting service definition. service Greeter { // Sends a greeting rpc SayHello (HelloRequest) returns (HelloReply) {} rpc StreamMessage (OpenRequest) returns (stream Message) {} } // The request message containing the user's name. message HelloRequest { string name = 1; string message = 2; } // The response message containing the greetings message HelloReply { string message = 1; } message OpenRequest { string name = 1; } message Message { string text = 1; }
Go側の実装はこんな感じ。
package main import ( "context" "errors" "fmt" "log" "net" pb "./helloworld" "google.golang.org/grpc" ) const ( address = ":50051" ) // server is used to implement helloworld.GreeterServer. type server struct { Streams map[string]pb.Greeter_StreamMessageServer Channels map[string]chan string } // SayHello implements helloworld.GreeterServer func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) { log.Printf("Received: %v, %v", in.GetName(), in.GetMessage()) if _, ok := s.Streams[in.GetName()]; !ok { return nil, errors.New("stream does not open") } if ch, ok := s.Channels[in.GetName()]; ok { ch <- "Hello " + in.GetMessage() } return &pb.HelloReply{Message: "Hello " + in.GetMessage()}, nil } func (s *server) StreamMessage(in *pb.OpenRequest, stream pb.Greeter_StreamMessageServer) error { if _, ok := s.Streams[in.GetName()]; !ok { if s.Streams == nil { s.Streams = make(map[string]pb.Greeter_StreamMessageServer) } s.Streams[in.GetName()] = stream if s.Channels == nil { s.Channels = make(map[string]chan string) } s.Channels[in.GetName()] = make(chan string, 1) } log.Println(in.GetName()) for { greeting := <-s.Channels[in.GetName()] for ch, stream := range s.Streams { if ch == in.GetName() { continue } msg := pb.Message{ Text: greeting, } if err := stream.Send(&msg); err != nil { log.Println(err) return err } } } return nil } func main() { lis, err := net.Listen("tcp", fmt.Sprintf("%s", address)) if err != nil { log.Fatalf("failed to listen: %v", err) } s := grpc.NewServer() pb.RegisterGreeterServer(s, &server{}) fmt.Println("start server") if err := s.Serve(lis); err != nil { log.Fatalf("failed to serve: %v", err) } }
C#側の実装はこんな感じ。
using System; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.ComponentModel; using System.Runtime.CompilerServices; using Grpc.Core; using Helloworld; namespace WpfApp9 { /// <summary> /// MainWindow.xaml の相互作用ロジック /// </summary> public partial class MainWindow : Window { public MainWindow() { InitializeComponent(); this.DataContext = new MainWindowVM(); } private void Tb1_TextChanged(object sender, TextChangedEventArgs e) { var vm = (MainWindowVM)this.DataContext; var tb = (TextBox)sender; vm.SayHello("ch1", tb.Text); } private void Tb2_TextChanged(object sender, TextChangedEventArgs e) { var vm = (MainWindowVM)this.DataContext; var tb = (TextBox)sender; vm.SayHello("ch2", tb.Text); } } public class MainWindowVM : INotifyPropertyChanged { #region Const const string CH1_NAME = "ch1"; const string CH2_NAME = "ch2"; #endregion #region Fields Channel channel1; Channel channel2; Greeter.GreeterClient greeter1; Greeter.GreeterClient greeter2; string _ch1_greeting; string _ch2_greeting; string _ch1_subscription; string _ch2_subscription; #endregion #region Events public event PropertyChangedEventHandler PropertyChanged; #endregion #region Constructor public MainWindowVM() { _ch1_greeting = ""; _ch1_subscription = ""; channel1 = new Channel("127.0.0.1:50051", ChannelCredentials.Insecure); greeter1 = InitChannel(channel1, CH1_NAME, new Action<string>((text) => Channel1Subscription = text)); _ch2_greeting = ""; _ch2_subscription = ""; channel2 = new Channel("127.0.0.1:50051", ChannelCredentials.Insecure); greeter2 = InitChannel(channel2, CH2_NAME, new Action<string>((text) => Channel2Subscription = text)); } #endregion #region Properties public string Channel1Greeting { get { return _ch1_greeting; } set { if (_ch1_greeting == value) return; _ch1_greeting = value; NotifyPropertyChanged(); } } public string Channel1Subscription { get { return _ch1_subscription; } set { if (_ch1_subscription == value) return; _ch1_subscription = value; NotifyPropertyChanged(); } } public string Channel2Greeting { get { return _ch2_greeting; } set { if (_ch2_greeting == value) return; _ch2_greeting = value; NotifyPropertyChanged(); } } public string Channel2Subscription { get { return _ch2_subscription; } set { if (_ch2_subscription == value) return; _ch2_subscription = value; NotifyPropertyChanged(); } } #endregion #region Method public void SayHello(string ch, string message) { HelloReply reply; switch(ch) { case CH1_NAME: reply = greeter1.SayHello(new HelloRequest { Name = CH1_NAME, Message = message}); Channel1Greeting = string.Format("Greeting: {0}", reply.Message); break; case CH2_NAME: reply = greeter2.SayHello(new HelloRequest { Name = CH2_NAME, Message = message}); Channel2Greeting = string.Format("Greeting: {0}", reply.Message); break; } } private void NotifyPropertyChanged([CallerMemberName] String propertyName = "") { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } private Greeter.GreeterClient InitChannel(Channel ch, string name, Action<string> act) { var g = new Greeter.GreeterClient(ch); Task.Run(async () => { using (var stream = g.StreamMessage(new OpenRequest() { Name = name })) { try { while (await stream.ResponseStream.MoveNext()) { var message = stream.ResponseStream.Current; act(message.Text); } } catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); } } }); return g; } #endregion } public class SayHelloCommand : ICommand { private static readonly Action EmptyExecute = () => { }; private static readonly Func<bool> EmptyCanExecute = () => true; private Action execute; private Func<bool> canExecute; public SayHelloCommand(Action execute, Func<bool> canExecute) { this.execute = execute ?? EmptyExecute; this.canExecute = canExecute ?? EmptyCanExecute; } public void Execute() { this.execute(); } public bool CanExecute() { return this.canExecute(); } bool ICommand.CanExecute(object parameter) { return this.CanExecute(); } public event EventHandler CanExecuteChanged; public void RaiseCanExecuteChanged() { this.CanExecuteChanged?.Invoke(this, EventArgs.Empty); } void ICommand.Execute(object parameter) { this.Execute(); } } }
XAMLはこんな感じ。
<Window x:Class="WpfApp9.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:WpfApp9" mc:Ignorable="d" Title="MainWindow" Height="450" Width="800"> <StackPanel> <TextBlock Text="Channel1" /> <DockPanel> <TextBlock Text="Text Box" Margin="0,0,10,5" /> <TextBox TextChanged="Tb1_TextChanged" /> </DockPanel> <DockPanel> <TextBlock Text="Greeting itself" Margin="0,0,10,5" /> <TextBlock Text="{Binding Channel1Greeting}" Background="AliceBlue"/> </DockPanel> <DockPanel> <TextBlock Text="Greeting from other" Margin="0,0,10,5" /> <TextBlock Text="{Binding Channel1Subscription}" Background="#FF8D9AC7" /> </DockPanel> <Border Margin="10"/> <TextBlock Text="Channel2" /> <DockPanel> <TextBlock Text="Text Box" Margin="0,0,10,5" /> <TextBox TextChanged="Tb2_TextChanged" /> </DockPanel> <DockPanel> <TextBlock Text="Greeting itself" Margin="0,0,10,5" /> <TextBlock Text="{Binding Channel2Greeting}" Background="AliceBlue"/> </DockPanel> <DockPanel> <TextBlock Text="Greeting from other" Margin="0,0,10,5" /> <TextBlock Text="{Binding Channel2Subscription}" Background="#FF8D9AC7"/> </DockPanel> </StackPanel> </Window>
protoファイルのビルド
Go編
protoファイルがある場所で以下のコマンドを実行。helloworld
のフォルダが既にあることが前提。
proto -I . helloworld.proto --go_out=plugins:grpc:helloworld
C#側
- クラスライブラリ(.Net Standard) のプロジェクトを作成
- NuGetで、
Grpc
とGrpc.Tools
とGoogle.Protobuf
をインストール - proto ファイルをプロジェクトに追加
- csproj に以下の行を追加 (PackageReferenceのItemGroupとは別に作成する)
<ItemGroup> <Protobuf Include="helloworld.proto" Link="helloworld.proto" /> </ItemGroup>
- ビルドを実行すると、dllが作成される。
NOTE
.Net Framework を使ってWPFアプリを作成する場合は、上記で作成したクラスライブラリのほかに、Grpc
と Google.Protobuf
をパッケージに追加する必要があるので、NuGetを使ってインストールしておくこと。.Net Standard の場合は クラスライブラリの中のパッケージを使用するようで、新たに追加する必要はなかった。
最終動作イメージ
以下のような動作となる。意外とシームレス?