この記事はGo 2 Advent Calendar 2020 24日目の記事です。
この記事について
デスクトップアプリをGoで作りたいと思ったこと、ありませんか。
私は以前ElectronというNode.js駆動のアプリフレームワークの記事を書いたものです。それから数ヶ月後にGopherになり、「GoでもElectronみたいにデスクトップアプリって作れるのかな?」と思い立ちました。
今回はその私の欲求にぴったりの(笑)、Goでデスクトップアプリを作れるWailsというパッケージの使い方を紹介します。
環境
- OS: macOS Mojave 10.14.5
- go version go1.14 darwin/amd64
- github.com/wailsapp/wails v1.7.1
Wailsとは?
バックエンドにGoを、フロントエンドにウェブテクノロジー(HTML/Javascript/CSS)を使ってデスクトップアプリを作るプラットフォームです。
v1が2019/12/10にリリースされたというフレッシュなフレームワークで、最近だと今年10/31にv1.9のアプデが入るなど、盛んに開発が行われている形跡があります。
なんでWails?
現状でもGoでGUIを作るツールは、少ないながらも存在はしているようです。
筆者がアプリを作ろうと思ったときに「go デスクトップアプリ
」とググっただけでも以下のようなツールが確認できました。
これら3つとwailsのGithubスター数を比較してみると、一番多いのはtherecipe/qtの8kです(2番がwailsの2.8k)。しかし、Qtの学習コストと、ビルドに時間がかかるという情報があったのでやめました。
あえてスター2番手のwailsを選ぶ理由としては、
- Awesome Goに記載があったこと
- 情報源としてGitHubのREADME以外にもきれいな公式サイトがあり、そこにチュートリアル(todoリスト作成)が存在したこと
が決め手となりました。
アーキテクチャ
フロントとバック
Wailsは、フロントにwebview(HTMLコンテンツを、アプリ内で表示できるようにするもの)を使用しています。したがって、画面構成のコーディングはHTML/CSS/JSで行うこととなります。
バックエンドは(もちろん)Goを利用しています。
画像出典:Concepts::Wails
IPC通信
WailsにはJS(フロント用)とGo(バック用)の、2つのランタイムが存在します。IPCのメカニズムを開発者が意識することなく、この2つのランタイム間でデータをやり取りするためのインターフェースを、Wailsでは2つ用意しています。
- bind: 関数や構造体といったGoのコードをフロントエンド側でも使えるように渡す仕組み。バックエンド側でのコードを直接フロントエンドで呼び出しているイメージ。
- events: イベントを発生させることで、一方から他方へと処理タイミングや値を渡す仕組み。JSでのイベントと似たシステム。
画像出典:Concepts::Wails
bindやeventsを実際に使ってみる様子は、この記事でも後ほど紹介します。
Hello, Worldの実行
この章では、Wailsを実際にインストールして起動し、アプリの開発を行えるようにするまでの過程を紹介します。
1. Wailsのインストール
MacだとGo1.12以上とnpmとgccライブラリが必要なので、まずはそれを用意してください(これのやり方については省略します)。
WailsにはCLIツールがあるので、まずはそれをインストールします。
$ go get -u github.com/wailsapp/wails/cmd/wails
# whichコマンドで正しくインストールされているのかを確認
$ which wails
/Users/myname/go/bin/wails
無事にCLIがインストールされたら、wails setup
コマンドを実行します。
$ wails setup
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ ) v1.7.1
|__/|__/\__,_/_/_/____/ https://wails.app
The lightweight framework for web-like apps
Welcome to Wails!
Wails is a lightweight framework for creating web-like desktop apps in Go.
I'll need to ask you a few questions so I can fill in your project templates and then I will try and see if you have the correct dependencies installed. If you don't have the right tools installed, I'll try and suggest how to install them.
What is your name: myname
What is your email address: myaddress@gmail.com
Wails config saved to: /Users/myname/.wails/wails.json
Feel free to customise these settings.
Detected Platform: OSX
Checking for prerequisites...
Program 'clang' found: /usr/bin/clang
Program 'npm' found: /Users/myname/.nodebrew/current/bin/npm
✓ Installing Mewn asset packer...
🚀 Ready for take off!
Create your first project by running 'wails init'.
実行すると上記のように、まずは名前とメールアドレスが聞かれます。
ここで入力した答えはconfigファイルに保存され(上記の例だと/Users/myname/.wails/wails.json
に保存)、今後Wailsプロジェクトを作るたびに、プロジェクトファイル中に記載する開発者の名前・メールアドレスの欄が自動入力されるようになります。
(npmでいうと、package.json
のauthor欄が自動で埋まるようなものです)
また、このときにWailsを動かすための依存パッケージがきちんと存在するかどうかも確認されます。
今回はMacで実行しているので、npmとclangが存在するかどうか確かめられている様子がわかります。
(もしも存在しない場合はインストールするようにここでサジェストされるようです)
2. プロジェクトのセットアップ
早速アプリのディレクトリを作っていきましょう。
Reactアプリがcreate-react-app
コマンドでアプリディレクトリが自動作成できるように、Wailsにもプロジェクトの雛形を一発で作ることができるwails init
コマンドというものが存在します。
$ wails init
Wails v1.7.1 - Initialising project
The name of the project (My Project): myapp
Project Name: myapp
The output binary name (myapp):
Output binary Name: myapp
Project directory name (myapp):
Project Directory: myapp
Please select a template:
1: Angular - Angular 8 template (Requires node 10.8+)
2: React JS - Create React App v3 template
3: Vanilla - A Vanilla HTML/JS template
4: Vue2/Webpack Basic - A basic Vue2/WebPack4 template
5: Vuetify1.5/Webpack Basic - A basic Vuetify1.5/Webpack4 template
6: Vuetify2/Webpack Basic - A basic Vuetify2/Webpack4 template
Please choose an option [1]: 3
Template: Vanilla
✓ Generating project...
✓ Building project (this may take a while)...
Project 'myapp' built in directory 'myapp'!
プロジェクト名や、フロントエンドは何で作るか(Angular, React, Vanilla, Vue, Vuetifyから選択)等の質問に答えていきます。
すると、myapp
ディレクトリが自動で作られ、その中にデスクトップアプリのコード叩き台が用意されます。
フロントエンドにVanilla JSを選んだ場合、以下のようなディレクトリ構造になります。
.
├─ build
│ └─ myapp # ビルドされたアプリのbinaryファイル
├─ frontend
│ ├─ build # アプリビルド時に、webpackでまとめられたフロントエンドコードがここに入る
│ │ ├─ index.html
│ │ ├─ main.css
│ │ └─ main.js
│ ├─ node_modules
│ ├─ src # このディレクトリ中のコードをいじってフロントエンドを開発する
│ │ ├─ index.html
│ │ ├─ main.css
│ │ └─ main.js
│ ├─ package.json
│ ├─ package-lock.json
│ ├─ package.json.md5
│ └─ webpack.config.js
├─ go.mod
├─ go.sum
├─ main.go # バックエンドのコードをここに書く
├─ project.json
└─ README.md
補足: フロントエンドに何を選ぶかによって、frontendディレクトリ内の構造は変わります。それでも、frontend/src内のソースコードをいじって開発するところは一緒です。
gitを使う場合は、node_modules
と、build
(ビルドされたアプリのバイナリファイルが格納)、build/frontend
(ビルド時にバンドルされたフロントエンドコードが格納)を管理対象外にするのが良いでしょう(以下、.gitignore
ファイルの記述例)。
node_modules
/build/
/frontend/build/
3. バックエンドの作成
バックエンドはmain.go
に記述します。
wails init
時のデフォルトでは、main.go
は以下のようになっています。
package main
import (
"github.com/leaanthony/mewn"
"github.com/wailsapp/wails"
)
func basic() string {
return "Hello World!"
}
func main() {
// 指定のjs,cssファイルをstring型に変換して代入
js := mewn.String("./frontend/build/main.js")
css := mewn.String("./frontend/build/main.css")
// CreateApp関数で、アプリのウィンドウを作る
app := wails.CreateApp(&wails.AppConfig{
Width: 1024,
Height: 768,
Title: "myapp",
JS: js,
CSS: css,
Colour: "#131313",
})
app.Bind(basic) // basic関数をBindする
app.Run() // アプリを動かす
}
ちなみにapp.Bind()
というのが、先ほど紹介したIPC通信のうちの"bind"機能を使うための関数です。
app.Bind()
の引数にfunc basic()
関数を渡したので、これでフロントJS側ではbackend.basic()
という形でこのGo関数を使うことができるようになりました。
4. フロントエンドの作成
フロントエンド(HTML,JS)はデフォルトで以下のようになっています(CSSは省略)。
<html>
<head>
<link rel="stylesheet" type="text/css" href="main.css">
</head>
<body>
<div id="app"></div>
<script src="main.js"></script>
</body>
</html>
import 'core-js/stable';
const runtime = require('@wailsapp/runtime');
// Main entry point
function start() {
// Ensure the default app div is 100% wide/high
var app = document.getElementById('app');
app.style.width = '100%';
app.style.height = '100%';
// Inject html
app.innerHTML = `
<div class='logo'></div>
<div class='container'>
<button id='button'>Click Me!</button>
<div id='result'/>
</div>
`;
// Connect button to Go method
document.getElementById('button').onclick = function() {
window.backend.basic().then( function(result) {
document.getElementById('result').innerText = result;
});
};
};
// We provide our entrypoint as a callback for runtime.Init
runtime.Init(start);
バックエンドでBindして渡したbasic()
関数は、frontend/src/main.js
の中で以下のように実際に使っています。前節3. バックエンドの作成
で紹介したように、backend.basic()
という形で呼び出していますね。
(ここでは、<div id='result'/>
のDOMの中身に、basic関数から得た返り値を格納しています)
// bindされたGoの関数をbackend.basic()で使用
window.backend.basic().then( function(result) {
document.getElementById('result').innerText = result;
});
5. アプリの起動
バックエンド・フロントエンドの準備ができたら、まずはアプリがどういう挙動をするのかを試しに起動して確かめてみましょう。
プロジェクトのルートディレクトリでwails serve
コマンドを実行します。
$ wails serve
Wails v1.7.1 - Serving Application
✓ Ensuring Dependencies are up to date...
✓ Packing + Compiling project...
Awesome! Project 'myapp' built!
Serving Application: /Users/myname/wails/myapp/build/myapp
myapp - Debug Build
-------------------
INFO[0000] [App] Starting
INFO[0000] [Events] Starting
INFO[0000] [IPC] Starting
INFO[0000] [Bind] Starting
INFO[0000] [Bind] Binding Go Functions/Methods
INFO[0000] [Bind] Bound Function: main.basic()
INFO[0000] [Events] Listening
INFO[0000] [Bridge] Bridge mode started.
INFO[0000] [Bridge] The frontend will connect automatically.
>>>>> To connect, you will need to run 'npm run serve' in the 'frontend' directory <<<<<
このようなTo connect~
という表示がされれば、バックエンド側の準備は整っています。
To connect, you will need to run 'npm run serve' in the 'frontend' directory
の指示通り、起動したバックエンドにフロントを接続します。
もう一つ別のターミナルを用意して、frontendディレクトリ直下でnpm run serve
を実行します。
$ cd frontend
$ npm run serve
補足: フロントエンドを起動するコマンドは、JSフレームワークに何を選んだかによって変わります。例えば、Reactを選択した場合はnpm run start
です。どちらにせよ、バックエンド起動時にターミナルに表れるコマンド通りにすればOKです。
npm run serve
を実行すると、このように自動的にウェブブラウザが開いて、現時点でのアプリの挙動が確認できます。
Vanilla JSのデフォルトでは、上の"Click Me!"のボタンを押すと、"Hello, world!"の文字が画面に表示されるアプリになっています。
テスト起動を終わらせるには、npm run serve
とwails serve
の両方をCtrl+Cで終わらせばOKです。
IPC通信~bindの詳細~
この章では、IPC通信インターフェースの一種であるbindについて説明します。
bindとは、「フロントエンド側で、Goのコードを呼び出すためのシステム」であり、「関数」と「構造体」に対して使用することができます。
関数のbind
関数をbindしたい場合は、バックエンドのmain関数の中でapp.Bind(関数名)
を実行します。
// bindしたい関数
func basic(name string) string {
return fmt.Sprintf("Hello, %s!", name)
}
func main() {
// (略)
app.Bind(basic) // 関数名basicを引数に渡す
app.Run()
}
bindされた関数は、フロントエンド側ではbackend.basic(引数)
で呼び出すことができ、これは返り値としてPromoiseオブジェクトを返します。
そのため、bindされたbasic
関数の返り値は、then
メソッドで取得します。
window.backend.basic("Alice").then( function(result) {
// resultには、"Hello, Alice!"が格納されている
document.getElementById('result').innerText = result;
});
構造体のbind
やり方
構造体をbindする場合は、バックエンドのmain関数の中で、app.Bind(構造体)
を実行します。
例えば、MyStruct
構造体をbindする場合は、以下のようにします。
type MyStruct struct {
Name string
}
// パブリックメソッド
func (s *MyStruct) CallName() string {
return s.Name
}
// プライベートメソッド
func (s *MyStruct) privateMethod() string {
return s.Name
}
func main() {
//(略)
stc := &MyStruct{Name: "Alice"}
app.Bind(stc) // 構造体の名前ではなくて、変数の名前を渡す
app.Run()
}
メソッドの扱い
構造体をbindした場合、JS側ではbindした構造体のパブリックメソッドをPromiseとして呼び出せるようになります。
例えば、MyStruct
のパブリックメソッドCallName()
は、backend.MyStruct.CallName()
と呼び出します。
backend.MyStruct.CallName().then(function(result) {
// Aliceと表示される
console.log(result)
});
プライベートメソッドであるprivateMethod()
は、JS側からは使うことができません。
// promiseメソッドにいかずにここでエラーが出る
// TypeError: backend.MyStruct.privateMethod is not a function.
// (In 'backend.MyStruct.privateMethod()', 'backend.MyStruct.privateMethod' is undefined)
backend.MyStruct.privateMethod().then(function(result) {
console.log(result)
});
構造体フィールドの扱い
プライベートメソッドと同様に、MyStructのフィールドを直接参照することもできません。
例えば、今回bindした構造体のNameフィールドを、backend.MyStruct.Name
とアクセスしようとしても、その変数はundefinedとなります。
// こうやっても、MyStructのNameフィールドは手に入らない
var Name = backend.MyStruct.Name
console.log(Name); // undefined
bindする関数・メソッドの引数
基本型
JS側でスカラー型(string, number, bool)を引数として渡した場合は、Go側でもそれらに対応する変数型で受け取ることができます。
Javascript | Go |
---|---|
string | string |
number | int,float64等 |
bool | bool |
JS側で渡した引数の中身がundefined,nullだった場合は、Go側ではそれぞれの型の初期値が自動的に入ります。
object型
JSのobject型に関しては、Goではmap[string]interface{}
に変換されます(プロパティの名前がmapのキーになります)。
var var1 = {
name: 'Bob',
age: 32,
interests: ['music', 'skiing'],
};
window.backend.basic(var1);
func basic(name map[string]interface{}) string {
fmt.Printf("%#v\n", name)
// map[string]interface {}{"age":32, "interests":[]interface {}{"music", "skiing"}, "name":"Bob"}
return "Hello World!"
}
なお、Objectの中に関数があった場合、それはGoでは取得できません。
var var1 = {
name: 'Bob',
age: 32,
interests: ['music', 'skiing'],
greeting: function() {
alert('Hi! I\'m ' + this.name + '.');
},
};
window.backend.basic(var1);
func basic(name map[string]interface{}) string {
fmt.Printf("%#v\n", name)
// map[string]interface {}{"age":32, "interests":[]interface {}{"music", "skiing"}, "name":"Bob"}
// greetingが存在しない
return "Hello World!"
}
不適切な型を渡してしまった場合
bindされた関数の引数の型として不適切な変数を渡してしまうと、エラーが発生します。
以下の例は、Go側でint型の引数を取るように設定された関数basic
に、JS側のbackend.basic()
でbool型の引数var1
を与えてしまった場合です。
window.backend.basic(var1).then( function(result) {
// (略)
}).catch((err) => {
// (例) value of type bool cannot be converted to type int for parameter 1 of function main.basic
alert(err);
});
このように、発生したエラーは、Promiseのcatchメソッドで拾うことができます。
bindする関数・メソッドの返り値
返り値の数・パターン
bindする関数・メソッドの返り値にはルールがあります。
まずは、「返り値は0~2個に収める必要があり、2個ある場合は2つめをエラーにしなくてはいけない」ということです。
つまり、bindする関数・メソッドは以下の形しか認められません。
// バックエンド側
// [result]はなんの型でも良い
func bindedFunc()
func bindedFunc() [result]
func bindedFunc() ([result], error)
返り値の構造体をJS側で参照するためには
また、返り値として構造体を返す場合で、JS側でフィールドにアクセスしたい場合は、それを公開フィールドにする必要があります。
例えば、次のようなbind関数を考えます。
type ReturnStruct struct {
number int
str string
}
func basic() ReturnStruct {
ans := ReturnStruct{
number: 1,
str: "a",
}
return ans
}
このとき、JS側でこの返り値のObjectを取得しようとすると、それにプロパティがないのです。
window.backend.basic().then( function(result) {
console.log(result); // {}
console.log(Object.keys(result)); // []
// numberとstrフィールドがあるはずなのに、JS側では見えない
});
ここで、ReturnStructのフィールド2つを公開します。
type ReturnStruct struct {
- number int
- str string
+ Number int
+ Str string
}
func basic() ReturnStruct {
ans := ReturnStruct{
- number: 1,
- str: "a",
+ Number: 1,
+ Str: "a",
}
return ans
}
すると、きちんとNumber
, Str
プロパティを参照することができるようになります。
window.backend.basic().then( function(result) {
console.log(result); // {Number: 1, Str: "a"}
console.log(Object.keys(result)); // ["Number", "Str"]
});
また、ReturnStructフィールドにjsonタグをつけると、タグ名でプロパティ名を指定することができます。
type ReturnStruct struct {
Number int `json:"number"`
Str string `json:"str"`
}
window.backend.basic().then( function(result) {
console.log(result); // {number: 1, str: "a"}
console.log(Object.keys(result)); // ["number", "str"]
// 大文字のフィールド名ではなく、jsonタグの名前がkeyになっている
});
IPC通信~イベントの詳細~
ここでは、bindとは別にもうひとつ存在するIPCインターフェース「イベント」について説明します。
イベントは、JS本来のイベント駆動システムとほぼ同じ考えです。JSとGoの一方で発生させたイベントをもう片方で拾うようにすることができます。
イベントを利用するための前準備
イベント発火を検知するためには、Wailsアプリのランタイムを見張っている必要があります。
そのため、*wails.Runtime
のフィールドを持つ構造体を用意して、それをあらかじめバインドしておくという下準備が必要です。
// runtimeを持つ構造体
type MyStruct struct {
runtime *wails.Runtime
}
// WailsInitは、アプリ起動時に処理が行われるメソッド
// アプリ起動時に、ランタイムフィールドを埋めておく
func (s *MyStruct) WailsInit(runtime *wails.Runtime) error {
s.runtime = runtime
return nil
}
func main() {
// (略)
// wails.Runtimeフィールドを持つ構造体をbind
item := &MyStruct{}
app.Bind(item)
app.Run()
}
JSでイベント発火→Goでキャッチ
基本
フロント側では、window.wails.Events.Emit(イベント名)
でイベントを発火させることができます。
window.wails.Events.Emit("testevent")
Goでそれをキャッチするには、WailsInit
の中で、runtime.Events.On
で常にランタイム上で発生するイベントを捕捉するように設定してやりましょう。
func (s *MyStruct) WailsInit(runtime *wails.Runtime) error {
s.runtime = runtime
// ランタイム上で発生する"testevent"イベントを監視するように設定
runtime.Events.On("testevent", func(...interface{}) {
fmt.Println("received testevent") // イベントキャッチしたら出力
})
return nil
}
データのやり取り
イベント発火と合わせて文字列や数値といったデータを送りたいなら以下のようにします。
var data = "I am a message from Javascript!"
- window.wails.Events.Emit("testevent")
+ window.wails.Events.Emit("testevent", data)
func (s *MyStruct) WailsInit(runtime *wails.Runtime) error {
s.runtime = runtime
- runtime.Events.On("testevent", func(...interface{}) {
+ runtime.Events.On("testevent", func(data ...interface{}) {
fmt.Println("received testevent")
+ fmt.Println(data)
})
return nil
}
Goでイベント発火→JSで受け取り
基本
バック側では、runtime.Events.Emit(イベント名)
でイベントを発火させます。
// runtimeフィールドを持つ構造体のメソッド
func (s *MyStruct) Method() {
runtime.Events.Emit("received")
}
フロント側でそれを受け取るには、window.wails.Events.On(イベント名, コールバック関数)
というようにします。
window.wails.Events.On("received", () => {
// イベントキャッチしたら実行
console.log("You've got received event!")
})
データのやり取り
イベントと一緒にデータを送るには以下のようにします。
func (s *MyStruct) Method() {
- runtime.Events.Emit("received")
+ runtime.Events.Emit("received", "wwwww")
}
- window.wails.Events.On("received", () => {
+ window.wails.Events.On("received", message => {
console.log("You've got received event!")
+ console.log(message) // wwwwwと出力
})
アプリのビルド
この章では、作成したアプリをパック・ビルドする過程を紹介します。
ビルドする
Wailsアプリをビルドするコマンドはwails build
です。それに-p
オプションをつけることで、.app
の形にパックすることができます。
補足:アプリ開発時に使っていたwails serve
コマンドはwails build
よりも軽量になるように作られているので、開発時・テスト時にはいちいちビルドしないでwails serve
を使うのが望ましいという記述が公式サイトにあります。
実際にアプリをビルドしてみます。
$ wails build -p
Wails v1.7.1 - Packaging Application
✓ Skipped frontend dependencies (-f to force rebuild)
✓ Building frontend...
✓ Ensuring Dependencies are up to date...
✓ Packing + Compiling project...
Awesome! Project 'myapp' built!
すると、/build
フォルダ内にパックされたアプリが出力されています。
実際に、このmyapp
を開いてみると、きちんとwails serve
で確認した通りの動作をするものができていることがわかります。
画面サイズの調節をできるようにする
しかし、ここでmyapp
の画面サイズの調節ができないことが気になる人もいるかと思います。
これをできるようにするためには、main.go
を少しだけいじる必要があります。
app := wails.CreateApp(&wails.AppConfig{
Width: 1400,
Height: 768,
Title: "myapp",
JS: js,
CSS: css,
+ Resizable: true,
Colour: "#131313",
})
アプリアイコンの設定
また、アプリのアイコンもデフォルトではWails公式ロゴがそのまま使われるようになっています。
実は、先ほどwails build -p
でビルドをしたときに、アプリディレクトリ内にappicon.png
という画像ファイルが自動で生成されているはずです。
.
├─ build
│ └─ (略)
├─ frontend
│ └─ (略)
├─ appicon.png ## これ
├─ go.mod
├─ go.sum
├─ main.go
├─ project.json
└─ README.md
そのため、自分オリジナルのアイコンにしたいのならば、アイコンにしたい画像をappicon.png
という名前で同じ場所においてからwails build -p
を実行すればOKです。
アイコン画像出展:IconArchive
まとめ
いかがだったでしょうか。
Electronと比べてしまうと「まだまだ発展途上だな」という印象ですが、Go言語とWebフロントエンドさえ書ける人であれば、学習コストをそうかけることなく簡単にデスクトップアプリが作れるのは魅力だと思います。
実際にWailsを利用したプロダクトもいくつか存在しているようですし、個人的にはもっと盛り上がってもよさそうに思います。
「ちょっと自分で使うGUIを作りたいな」というときに、是非Go(Wails)を選択肢に入れてみませんか?