AngularとSpringBootでWebページを作ってみたい その2
はじめに
引き続きWebページを作成すべくあれこれ試しています。
今回は、Angularでページを作っていく上で必要になった知識?も合わせてまとめることにします。
node_moduleをインストールし直す
1台のマシンだけで開発している場合は問題がないのですが、
他のマシンにプロジェクトを移したい場合、node_moduleは持っていってもエラーになってしまいます。
(.gitignoreに含まれてもいます)
そのため、プロジェクトを移したあとで「npm install」を行い、node_moduleをインストールし直す必要があります。
インストールされる内容は、プロジェクト直下にあるpackage.jsonのものです。
なお、この中のアプリのバージョン(下記参照)を更新したい場合は、手動で書き直すのでしょうか…?
package.json
{ "name": "anime-crud-sample", "version": "0.0.0", "license": "MIT", 〜省略〜
ビルドデータの出力先の変更
以前書きましたが、
Angularで作成したページを実際サーバー上で表示するときは、「ng build」でビルドしたデータを使うことになります。
通常ではプロジェクト直下に作成される「dist」というフォルダの中にビルドしたデータが出力されるのですが、
これを任意の場所に出力するためには、プロジェクト直下にある.angular-cli.jsonを変更します。
.angular-cli.json
{ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "project": { "name": "anime-crud-sample" }, "apps": [ { "root": "src", "outDir": "ここを変更する" 〜省略〜
CSS Grid Layoutを使う
以前は下記を再現するには、tableを使用して結合などを利用していました。
しかし、CSS Grid Layoutを使うことでより簡単に再現できるようになります。
さっそく試してみます。
元のHTML
< div class='top-news-item-area'> < div class='top-news-item-date'>2017年6月20日< /div> < div class='top-news-item-title'>ニュースだよ< /div> < div class='top-news-item-article'>中身です< /div> < /div>
top-news-item-area(クラスを持つdiv)の中に、3つのdivが含まれています。
このHTMLに対して設定していきます。
top-news-item-area
まず、このLayoutが何行何列なのか、またそれぞれの高さ・幅を、
3つのdivの親であるtop-news-item-areaに設定します。
.top-news-item-area{ display: grid; /* Grid layoutを有効にする */ grid-template-rows: 50px 100px; /* 左から順に1行目、2行目の高さを指定 */ grid-template-columns: 20% 1fr; /* 左から順に1列目、2列目の幅を指定 */ }
- 今回は2行2列のため、grid-template-rowsとgrid-template-columnsにはそれぞれ2つずつ値を設定しています。
行・列数を増やす場合は更に値を追加していきます。 - grid-template-columnsで指定している「1fr」は、1列目で指定した幅の残りを2列目に全て割り当てるために使用しています。
top-news-item-date・top-news-item-title・top-news-item-article
Layoutの行・列数と高さ・幅が決まれば、あとは子となるdivの配置を決めるだけです。
.top-news-item-date{ grid-row: 1 / 3; /* 1行目から3行目まで(つまり2行全て)を指定する */ grid-column: 1 / 2; /* 1列目から2列目まで(つまり1列目)を指定する */ border: 1px solid #000; } .top-news-item-title{ grid-row: 1; /* 1行目を指定する */ grid-column: 2; /* 2列目を指定する */ border: 1px solid #000; } .top-news-item-article{ grid-row: 2 / span 1; /* 2行目から1行分(つまり2行目のみ)を指定する */ grid-column: 2; /* 2列目を指定する */ border: 1px solid #000; }
- top-news-item-titleのように1行または1列だけを指定する場合は、「/」以降の数字を省略できます。
- 「/」以降を省略した場合、top-news-item-articleのgrid-rowと同じように、指定の行(または列)から1行(列)分が設定されます。
結果
ここまでのものを表示すると、下記のようになります。
あとは角丸にしたり、marginなどで幅を調整したりすればOKです。
課題
Chrome、Firefoxだけであれば問題なく表示できるのですが、
少なくともAngularでCSS Grid Layoutを使うと、警告出る場合があります。
IEでは、「grid-row: 1 / 3;」のように「grid-row(またはgrid-column)」で「/」以降の数字を指定できないためです。
手元にIEがないため未確認ですが、下記のようにすればIEにも対応できるようです。
.top-news-item-area{ display: grid; display: -ms-grid; grid-template-rows: 50px 100px; grid-template-columns: 20% 1fr; -ms-grid-rows: 50px 100px; -ms-grid-columns: 150px 1fr; } .top-news-item-date{ -ms-grid-row: 1; -ms-grid-row-span: 2; -ms-grid-column: 1; -ms-grid-column-span: 1; grid-row: 1 / 3; grid-column: 1 / 2; border: 1px solid #000; } .top-news-item-title{ -ms-grid-row: 1; -ms-grid-column: 2; grid-row: 1; grid-column: 2; border: 1px solid #000; } .top-news-item-article{ -ms-grid-row: 2; -ms-grid-column: 2; grid-row: 2; grid-column: 2; border: 1px solid #000; }
ただし警告は消えません。。。
まぁこれに関する警告をでないようにすれば(たぶんtslint.json)良いのでしょうが、
ちょっと気持ち悪いですね。。。
RxJSでJSONデータ取得
さて、CSS Grid Layoutで作ったフォームに、データを入れてみます。
Spring bootでGETリクエストをした時にJSONを返すようにしておき、
RxJSを使ってそれを受け取ってHTMLに渡す、という処理を行います。
Spring boot
今回は一旦GETリクエストがあった場合に、JSONデータを固定値で生成して返す、という処理をすることにします。
ということで、「spring-boot-starter-web」を含めてSpring bootのプロジェクトを作成し、下記のクラスを追加します。
News.java
public class News { public int id; public String createdDate; public String title; public String article; }
- JSONデータを返すための値を保持するクラスです。
CrudSampleController.java
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.util.ArrayList; import java.util.List; @RestController public class CrudSampleController { @GetMapping("/topnewslist") public ListnewsList(){ List newsItems = new ArrayList<>(); News newsItem1 = new News(); newsItem1.id = 0; newsItem1.createdDate = "2017.06.22"; newsItem1.title = "うどん一杯目"; newsItem1.article = "きつねうどん"; newsItems.add(newsItem1); News newsItem2 = new News(); newsItem2.id = 1; newsItem2.createdDate = "2017.06.23"; newsItem2.title = "うどん二杯目"; newsItem2.article = "味噌煮込みうどん"; newsItems.add(newsItem2); return newsItems; } }
- Controllerクラスです。
- localhost:8080/topnewslistにGETリクエストがあった場合にnewsList()が呼び出され、
Newsクラスのリストを返します。
(リストやクラスを戻り値にすると、自動でJSONとして受け取ることができるの便利ですね)
RxJS
フロント側に戻ります。
AngularのプロジェクトにはデフォルトでRxJSが含まれているため、
インストールなどは特にしなくてもそのまま使用できます。
GETリクエストを送るためには、app.module.tsにHTTPModuleを追加する必要があります。
app.module.ts
import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { NgModule } from '@angular/core'; import {HttpModule} from '@angular/http'; import { AppComponent } from './app.component'; import { SlideAnimeComponent } from './slide-anime/slide-anime.component'; import { FormsModule } from '@angular/forms'; import { GlobalHeaderComponent } from './global-header/global-header.component'; import { ContactPageComponent } from './contact-page/contact-page.component'; import { routing } from './app.routing'; import { MainPageComponent } from './main-page/main-page.component'; import { TopBannerComponent } from './top-banner/top-banner.component'; import { TopNewsComponent } from './top-news/top-news.component'; import { MenulistPageComponent } from './menulist-page/menulist-page.component'; @NgModule({ declarations: [ AppComponent, SlideAnimeComponent, GlobalHeaderComponent, ContactPageComponent, MainPageComponent, TopBannerComponent, TopNewsComponent, MenulistPageComponent ], imports: [ BrowserModule, BrowserAnimationsModule, FormsModule, HttpModule, routing ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
あとはHTTPとRxJSを使ってJSONデータを取得します。
top-news.ts
export interface TopNews { id: number; createdDate: string; title: string; article: string; }
- サーバー側から受け取ったJSONデータを格納するためのインターフェースです。
top-news.component.ts
import { Component, OnInit } from '@angular/core'; import { TopNews } from '../top-news'; import { Http } from '@angular/http'; import 'rxjs/add/operator/map'; @Component({ selector: 'app-top-news', templateUrl: './top-news.component.html', styleUrls: ['./top-news.component.css'] }) export class TopNewsComponent implements OnInit { newsList: TopNews[]; constructor(private http_: Http) { http_.get("/topnewslist") .map(response => response.json()) /* response.json()の「()」を忘れないこと */ .subscribe(gotNews => this.newsList = gotNews); } ngOnInit() { } }
- 今回は初回ロード時のみデータを読み込むため、constructorで、DIでインスタンスを受け取ったらそのまま処理を行っています。
- constructor以外でも「this.http_」でアクセスできるため(なんだか不思議な感じがしますが)、任意のタイミングで実行することも可能です。
- 「map(response => response.json())」で、response.json()の「()」がついていなくてもエラーにはならないのですが、
戻り値が変わってしまい、subscribeの時に値が取り出せなくなってしまいます。
注意点
上記コードをng serveを使い、ビルドしない状態で実行するとエラーが発生します。
それは、同一ドメイン以外のURLにアクセスしに行く場合に必要なXMLHttpRequestの設定が抜けており、
ブラウザによってアクセスを禁止されるためです。
ただ今回は、最終的には同じドメインでやり取りをすることになるため、
ng buildでビルドを行い、Spring bootのプロジェクトに組み込んだ状態で確認を行うことにします。
おわりに
今回はほとんど触れられませんでしたが、RxJSももう少し突っ込んで調べてみたいと思います。
参照
Angular
CSS Grid Layout
- CSS グリッドレイアウト - CSS - MDN
- グリッド レイアウト (Windows) - msdn.aspx)
- CSS Grid Layout Module Level 1
- CSS Grid Layout を極める!(基礎編) - Qiita
DI
AngularとSpringBootでWebページを作ってみたい その1
はじめに
ひょんなことから試しにWebサイトを作ってみることにしました。
フロントはAngular、バックはSpring bootで、以下の機能を持たせてみます。
- ページは以下の3ページ
- 1.トップ
- 2.メニュー一覧
- 3.お問い合わせ
- 1.トップでは 1-1. バナー画像、1-2. ニュース を表示
- 1-1.バナー画像は複数画像(アイテム数に合わせて動的に設定)を矢印キーまたはタイマーで切り替え
- 1-2. ニュースはDBに登録されている内容を非同期で取得して表示する
- 各画面へはヘッダーにボタンを設置し、それをクリックして遷移する
機能や 2.メニュー一覧 の子どものページなど追加したい部分はありますが、
まずは上記をやってみることにします。
ヘッダーを作る
※2017.06.23更新
ページ遷移の方法を、「location.href=“/”;」のようにするのではなく、
「< button routerLink=“/” class=“header-button” >Home< /button >」とするよう変更しました。
切り替えのたびにページ全体がリロードしてしまうためです。
global-header.component.html
< header id="global-header" > < img width="300" src="https://pbs.twimg.com/media/DClPJ4qUIAAROmQ.jpg" id="header-logo" > < button routerLink="/" class="header-button">Home< /button> < button routerLink="./menulist" class="header-button">Menu< /button> < button routerLink="./contact" class="header-button">お問い合わせ< /button>
global-header.component.css
#global-header{ background-color: #2980B9; width: 100%; min-width: 1200px; height: 90px; } #header-logo{ margin: 10px; width: 70px; height: 70px; float: left; } .header-button{ background-color: #2980B9; border-style: none; color: #FFFFFF; font-size: 18px; width: 160px; height: 60px; margin: 30px 20px 0px 0px; float: left; } .header-button:hover{ background-color: #FFF; color: #000; font-weight: bold; }
global-header.component.ts
import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-global-header', templateUrl: './global-header.component.html', styleUrls: ['./global-header.component.css'] }) export class GlobalHeaderComponent implements OnInit { constructor() { } ngOnInit() { } }
ページ遷移はHTML側で行うため、Typescriptでは何もしていません。
ルーティング
(開発環境では)localhost:4200以下の各URLにアクセスした時に、
該当のページを表示するため、ルーティングを行います。
まずはsrc/app以下にapp.routing.tsを作成します
(これはangular.cliを使うのではなく、エディタから作成するようです)。
app.routing.ts
import { ModuleWithProviders } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { MainPageComponent } from './main-page/main-page.component'; import { ContactPageComponent } from './contact-page/contact-page.component'; const appRoutes: Routes = [ { path: '', component: MainPageComponent }, { path: 'contact', component: ContactPageComponent } ]; export const routing: ModuleWithProviders = RouterModule.forRoot(appRoutes);
- まだ2.メニュー一覧は追加されていません。
これをapp.module.tsに登録して、
ルーティング処理を有効にします。
app.module.ts
import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { SlideAnimeComponent } from './slide-anime/slide-anime.component'; import { FormsModule } from '@angular/forms'; import { GlobalHeaderComponent } from './global-header/global-header.component'; import { ContactPageComponent } from './contact-page/contact-page.component'; import { routing } from './app.routing'; import { MainPageComponent } from './main-page/main-page.component'; import { TopBannerComponent } from './top-banner/top-banner.component'; import { TopNewsComponent } from './top-news/top-news.component'; @NgModule({ declarations: [ AppComponent, SlideAnimeComponent, GlobalHeaderComponent, ContactPageComponent, MainPageComponent, TopBannerComponent, TopNewsComponent ], imports: [ BrowserModule, BrowserAnimationsModule, FormsModule, routing ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
バナーを作る
トップページに、バナーを表示してみます。
top-banner.component.html
< section id="top-banner-area" > < img [src]="[imageSrcPaths[activeImageNum]]" class="top-banner" [@bannerState]="topBannerState" (@bannerState.done)="onBannerChanged()" /> < /section >
- [@bannerState]と(@bannerState.done)は次の項目で触れます。
- imgのsrcとして配列(今回は固定)に渡したURLのうち一つを渡し、表示しています。
top-banner.component.css
#top-banner-area{ background-color: #000; padding-left:300px; width: 900px; height: 600px; } .top-banner{ width: 600px; height: 600px; }
- 例によってサイズは適当です。
アニメーションをつける
アニメーションで画像を切り替えてみます。
基本的には以前やったものと同じような内容です。
top-banner.component.ts
import { Component, OnInit, HostListener } from '@angular/core'; import { animate, state, style, transition, trigger } from '@angular/animations'; @Component({ selector: 'app-top-banner', templateUrl: './top-banner.component.html', styleUrls: ['./top-banner.component.css'], animations: [ trigger('bannerState', [ state('active', style({ transform: 'scale(1.0)', filter: 'brightness(100%)' })), state('inactive', style({ transform: 'scale(0.6)', filter: 'brightness(20%)' })), transition('active => inactive', animate('400ms ease-in')), transition('inactive => active', animate('10ms ease-in')) ] )] }) export class TopBannerComponent implements OnInit { private imageSrcPaths: string[]; private activeImageNum: number; private nextImageNum: number; private topBannerState: string; constructor() { } ngOnInit() { this.imageSrcPaths = [ "https://pbs.twimg.com/media/DClPJ4qUIAAROmQ.jpg", "https://pbs.twimg.com/media/DCH75wfU0AAievH.jpg" ]; this.activeImageNum = - 1; this.nextImageNum = 0; this.topBannerState = 'inactive'; this.onBannerChanged(); } 〜省略〜 onBannerChanged(){ // アニメーション完了後に次の画像に切り替え. // active->inactive、inactive->activeの両方で呼ばれてしまうので後者を無視. if(this.activeImageNum === this.nextImageNum){ return; } this.activeImageNum = this.nextImageNum; this.topBannerState = 'active'; }
- あとはtopBannerStateを「active」「inactive」に切り替えてあげればOKです。
- アニメーションでは画像のサイズを変える他、「filter: brightness」で明るさも変更しています。
注意点
注意すべき点として、Angular4からanimationが@angular/coreから分離されています。
ただ、まだ@angular/coreから削除はされていないため state、style、transition、trigger のimportができてしまうのですが、
@angular/animationsからimportしないとエラーが発生するようです。
また、app.module.tsにBrowserAnimationsModuleを追加する必要があります。
app.module.ts
import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { SlideAnimeComponent } from './slide-anime/slide-anime.component'; import { FormsModule } from '@angular/forms'; import { GlobalHeaderComponent } from './global-header/global-header.component'; import { ContactPageComponent } from './contact-page/contact-page.component'; import { routing } from './app.routing'; import { MainPageComponent } from './main-page/main-page.component'; import { TopBannerComponent } from './top-banner/top-banner.component'; import { TopNewsComponent } from './top-news/top-news.component'; @NgModule({ declarations: [ AppComponent, SlideAnimeComponent, GlobalHeaderComponent, ContactPageComponent, MainPageComponent, TopBannerComponent, TopNewsComponent ], imports: [ BrowserModule, BrowserAnimationsModule, FormsModule, routing ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
キーイベントを取る
さて、次はバナー画像の表示切り替えを、左右の矢印キーを押したときに行うようにしてみます。
キー入力のイベントは、例えばinputタグであれば下記のように書くことができます。
< input (keyup)="onArrowKeyUp($event)" >
これをTypeScript側でevent.keyCodeのようにすれば、押されたキーを判別できます。
top-banner.component.ts
〜省略〜 onArrowKeyUp(event: any){ if(event.keyCode === 39){ // RightArrow. // 右矢印キーが押されたときの処理. } else if(event.keyCode === 37){ // LeftArrow // 左矢印キーが押されたときの処理. } } 〜省略〜
が、inputではなくdivタグなどではうまくイベントを取ることができません。
document.onkeyupに、ngOnInit()などでメソッドを追加することはできるものの、
そのままではクラス内で定義している変数などにアクセスできない問題が起こります。
でどうするかというと、「HostListener」を使います。
top-banner.component.ts
import { Component, OnInit, HostListener } from '@angular/core'; import { animate, state, style, transition, trigger } from '@angular/animations'; 〜省略〜 @HostListener('document:keyup', ['$event']) onArrowKeyUp(event: any){ if(event.keyCode === 39){ // RightArrow. // 右矢印キーが押されたときの処理. } else if(event.keyCode === 37){ // LeftArrow // 左矢印キーが押されたときの処理. } } 〜省略〜
Javaのアノテーションのような感じで、取得したいイベント(document.onkeyup)とその引数($event)をそれぞれ指定すると、
直下(または直後?)のメソッドが、該当イベントの発生時に呼ばれるようになります。
top-banner.component.tsのコード
最後に、ここまでのtop-banner.component.tsのコードを載せておきます。
top-banner.component.ts
import { Component, OnInit, HostListener } from '@angular/core'; import { animate, state, style, transition, trigger } from '@angular/animations'; @Component({ selector: 'app-top-banner', templateUrl: './top-banner.component.html', styleUrls: ['./top-banner.component.css'], animations: [ trigger('bannerState', [ state('active', style({ transform: 'scale(1.0)', filter: 'brightness(100%)' })), state('inactive', style({ transform: 'scale(0.6)', filter: 'brightness(20%)' })), transition('active => inactive', animate('400ms ease-in')), transition('inactive => active', animate('10ms ease-in')) ] )] }) export class TopBannerComponent implements OnInit { private imageSrcPaths: string[]; private activeImageNum: number; private nextImageNum: number; private topBannerState: string; constructor() { } ngOnInit() { this.imageSrcPaths = [ "https://pbs.twimg.com/media/DClPJ4qUIAAROmQ.jpg", "https://pbs.twimg.com/media/DCH75wfU0AAievH.jpg" ]; this.activeImageNum = - 1; this.nextImageNum = 0; this.topBannerState = 'inactive'; this.onBannerChanged(); } @HostListener('document:keyup', ['$event']) onArrowKeyUp(event: any){ if(event.keyCode === 39){ // RightArrow. this.nextImageNum = (this.activeImageNum < this.imageSrcPaths.length - 1)? this.activeImageNum + 1 : 0; this.topBannerState = 'inactive'; } else if(event.keyCode === 37){ // LeftArrow this.nextImageNum = (this.activeImageNum > 0)? this.activeImageNum - 1: this.imageSrcPaths.length - 1; this.topBannerState = 'inactive'; } } onBannerChanged(){ if(this.activeImageNum === this.nextImageNum){ return; } this.activeImageNum = this.nextImageNum; this.topBannerState = 'active'; } }
おわりに
次はバナー画像をタイマーで切り替えるようにしたり、
非同期でニュースのデータを取得して表示できるようにしたいと思います。
参照
Angular
- hands-on/courses/tutorial - ng-japan/hands-on - GitHub
- Angular 2 Cookbook - O'Reilly Media
- Angular 4 Property Binding Tutorial
Animation
KeyEvent
- User Input - Angular
- Attribute Directives - Angular
- Angular2でWindowイベントを設定する - イソップブログ
- Angular: How do I register an event listener on document? - Juri’s TechBlog
- angular - Update input value on keyboard event - Angular2 - Stack Overflow
CSS
PlayModeでUnity Test Toolsを使ってみた
はじめに
Unityでテストを書くのに使用するUnity Test Tools。
Unity5.3から組み込みとなり、AssetStoreで別途インストールする必要はなくなりました。
ただ、CoroutineやTween系(iTweenやDOTweenとか)は確認することができず、別の方法でテストを行う必要がありました。
が、5.6から強化され、PlayModeでテストを行うことができるようになりました。
ということで今回は、PlayModeでDOTweenを使って3Dモデルを移動させるコードをテストしてみることにしました。
準備
前述の通り、Unity Test Toolsを使うのに何かをインストールする必要はありません。
ただし、デフォルトではPlayModeでのテスト実行が無効になっているため、
これを有効にします。
- メニューの Window > Test Runner からTest Runnerウインドウを開く
- PlayMode タブを開き、 Enable playmode tests をクリックして有効にする
- UnityEditorを再起動する
あとは PlayMode タブの Create PlayMode test または右クリック > Create > Testing > PlayMode Test C# Script からテスト用クラスを追加します。
(※PlayMode用のファイルは、Editorフォルダ内に入れてしまうとPlayMode用のTest Scriptと認識されないようなので、
それ以外の場所に保存する必要があります)
テスト対象のコード
テスト対象のコードは、下記の関数 Move とします。
ObjectController.cs
using DG.Tweening; using UnityEngine; public class ObjectController : MonoBehaviour { public GameObject EventHandleObject; protected ObjectCtrlEventHandler ObjectEventHandler; public Tweener Move(GameObject targetObject, Vector3 goalPosition, float duration, Ease easeType) { return targetObject.transform.DOMove(goalPosition, duration) .SetEase(easeType) .OnComplete(ObjectEventHandler.OnFinished); } private void Awake() { ObjectEventHandler = EventHandleObject.GetComponent(); } }
ObjectCtrlEventHandler.cs
using UnityEngine; public class ObjectCtrlEventHandler : MonoBehaviour { public void OnFinished() { Debug.Log("Finished"); } }
- 引数として渡しているGameObject(targetObject)を、goalPositionの位置までduration秒で移動させる、という内容です。
- 移動が完了したら ObjectCtrlEventHandler > OnFinished が呼ばれます。
テストを書く
この関数に対するテストを書きます。
ObjectControllerTest.cs
using System.Collections; using DG.Tweening; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; namespace Tests { public class ObjectControllerTest: ObjectController { private GameObject targetObject; [SetUp] public void Init() { // 初期化処理. JUnitでいう@Before. EventHandleObject = new GameObject(); ObjectEventHandler = EventHandleObject.AddComponent(); targetObject = new GameObject(); } [Test] public void ObjectControllerTestSimplePasses() { // 待ち時間が不要な処理はTestを使う. } [UnityTest] public IEnumerator MoveIn0Sec() { var tween = Move(targetObject, Vector3.one, 0f, Ease.Flash); // Tweenerを返す場合OnCompleteの処理をダミーに置き換えることができる. tween.OnComplete(() => Debug.Log("")); // 結果が戻るのが次フレーム以降のため、少し待つ. yield return new WaitForSeconds(0.1f); Assert.AreEqual(targetObject.transform.position, Vector3.one); } [TearDown] public void Dispose() { // 終了処理. JUnitでいう@After. } } }
Testの実行
上記のようなTest Scriptを作成すると、Test Runnerウィンドウ > PlayModeタブ に該当のTestが表示されます。
あとは「Run All」や「Run Selected」をクリックすればテストが実行されます。
NUnit
- テストコードは、NUnit(ver.2.6.4)がベースになっており、初期化処理([SetUp])、終了処理([TearDown])が使用できます。
初期化処理
- テスト対象である ObjectController のAwakeでGetComponentしている ObjectCtrlEventHandler は、
Testでは(GameObjectにアタッチができないため)NullReferenceExceptionになるので SetUp で値をセットしています。
DOTweenの処理が反映されるタイミング
- DOTweenで処理を行う場合、durationを0にしていても同フレーム内では座標値の変更が反映されないため、 WaitForSeconds で待ち時間を設けます。
- [Test] では戻り値がvoidに限定されるため、 [UnityTest] を使って戻り値を IEnumerator にし、yield return new WaitForSeconds を使用しています。
おわりに
これまで以上にテスト可能な箇所が増えたことで、ぐんとテストが書きやすくなったように思います。
一点気になっているのは、例えばJenkinsなどのCIツールを利用する場合にも、今回のテストは実行できるのか?ということです。
こちらについては近いうちに試してみたいと思います。
参照
- Unity Test Runner - Unity - Manual ※2017.5.27現在英語版と日本語版でバージョンが異なるため注意
- Unity-Technologies / UnityTestTools — Bitbucket
- Unity5.3からUnit Tests Runnerが組み込みになった - Qiita
- 実践 JUnit
JJUG CCC 2017 Springに行ってきました
- はじめに
- G+H 1: 非機能要件とSpring Boot
- G+H 2: Vue.js + Spring Bootで楽しくフルスタック開発やってみた
- F 3: SpotBugs(FindBugs)による大規模ERPのコード品質改善
- G+H 4:Javaエンジニアに知って欲しいRDBアンチパターン
- E5: Javaで実装して学ぶOAuth 2.0!
- A+B 6: Java8プログラミング ベストプラクティス & きしだが働いてるかどうかIDEのメモリ使用状況から機械学習で判定する
- M 8: Ordinary Object Pointer について調べてみた
- 懇親会LT
- おわりに
はじめに
5/20に行われたJJUG CCC 2017 Springに参加してきました。
感想を一言で言えば、行ってよかった楽しかった!ということに尽きます。
…まぁもう少したくさんの方と話できればよかったとか、後述する懇親会でのLTの話とか思うことも色々ありはしますが。
せっかくなので、各セッションの感想などを書いてみたいと思います。
なお、スライド資料は下記を参照することにします。
JJUG CCC 2017 Springスライドまとめ(随時更新) #jjug_ccc - メンチカツには醤油でしょ!!
※各感想は私の理解に基づくもので、今後資料を見直していく中で間違いを見つけましたら修正する予定です。
G+H 1: 非機能要件とSpring Boot
まず非機能要件とは何か、という話からでしたが、
よくよく考えてみると機能要件と非機能要件の分類って難しくね?という話に。
この分類についてIPAの非機能要求グレードが紹介され、
非機能要求を段階的に細分化していく様子を見せてくれていました。
後半は、それらをSpring Bootで実装する方法について。
ここで登場していたSpring Actuatorは、前から気になっているSpring Securityなどに加えて、自分でも触ってみたいと思いました。
G+H 2: Vue.js + Spring Bootで楽しくフルスタック開発やってみた
フロントエンドでVue.jsを使っていくための構成や、Spring Bootに組み込む上で効率的なビルド方法などについて紹介されました。
変更を即時反映するところなどはAngularなどでも同じような機能が用意されているため、
Vue.jsだけでなく、今のJSフレームワークを使って開発する場合に参考にできる、と感じました。
F 3: SpotBugs(FindBugs)による大規模ERPのコード品質改善
FindBugsをForkしたSpotBugsがなぜ生まれたのか、また数ある静的解析ツールの中から、
なぜSpotBugs(FindBugs)が選ばれたのかなどが紹介されました。
現状では、静的解析ツールは一つだけでなんでも賄うのではなく、
状況に合わせて組み合わせるしかないこと、また開発効率が落ちると使ってくれなくなるため、速度重視の解析と網羅性を高めた解析を組み合わせる、という話が印象的でした。
G+H 4:Javaエンジニアに知って欲しいRDBアンチパターン
DBというのはアプリケーションのコードやサービスより長く使われることが多いため、
最初の設計がまずいと後々大きな負債になってしまう、という話からスタート。
RDBのアンチパターンを面白く紹介しつつ、しっかりと最初に設計を行うこと、
また状況に合わせてリファクタリング(移行期間はアプリケーションのコードなどと比べ長くかかる)を行う必要がある、ということを説明されていました。
また、モニタリングの監視を行うことで、
障害が発生する前にあらかじめ対処しておく必要がある、というところも印象的でした。
E5: Javaで実装して学ぶOAuth 2.0!
OAuth2.0の基礎からJava EEでどう実現するかを紹介されていました。
まずは認可と認証の違いや下記の4人の登場人物と、それぞれの役割について。
- リソースオーナー
- リソースサーバー
- クライアント
- 認可サーバー
そして認証、認可の流れを噛み砕いて説明、という内容でした。
OAuth2.0の仕様としてすべてが決まっているわけではなく、
実現しなければいけないことだけが決まっていて、具体的にどうするかは決まっていない、という部分があるのが印象的でした。
また、いくどとなく仕様を正しく理解する必要性を強調されていたのも印象的でした。
柔らかく解説されていたとはいえ、完全に理解するためにはもう少し今回のスライドも含めて資料を読み込む必要があると思いますが、
どういうことを理解していかなければいけないのか、という概要はわかったような気がしました。
また、合間合間の時間確認とともに差し込まれるCM(スポンサー枠の発表でした)が、
だんだん笑いを誘うようになってきたあたり、(狙っていたのかはともかく)とてもうまいと思いました。
対象が異なるとはいえ、どうしても同じような内容になってしまうCMを、楽しく聴くことができたので。
あと、最後のデモは見ているだけで胸が痛く。。。(´・ω・`)
A+B 6: Java8プログラミング ベストプラクティス & きしだが働いてるかどうかIDEのメモリ使用状況から機械学習で判定する
2本立ての内容で、まずはNetBeansのメモリ使用状況を元に、
どのようなデータを使ってNetBeansを活発に使用している・していないかを判断したのか、またその判定の方法が紹介されました。
諸事情から内容がコンパクトになったとのことでしたが、しっかりオチもあっておもしろかったですw
2つ目はプログラミングベストプラクティスで、全体的なところでは可能な限りImmutableにすること、
Java8以上の話ではStream、Optionalを中心に紹介されました。
下記は特に印象に残っています。
- 変数にダミーで値を入れて、状況に応じてその変数に再代入するより、変数をカラにしておいて、条件に応じて値を入れる方が最適化されやすい
- (StreamのforEachの中でif文を使う処理の話で)Optionalは、要素数が一つだけのStreamと見なして、同じような処理を行うことができる。
M 8: Ordinary Object Pointer について調べてみた
Javaのオブジェクトの内部表現であるOrdinary Object Pointerのお話。
例えばString型の変数は内部的にどう表現され、それは何Byteになるのか、というような内容。
正直なところ知識がなさすぎて、ただただなるほど〜と感心するばかりでした。
とはいえ、プログラムの中身が実際にはどうなっているのか、という興味はあるので、
今後もう少し時間をかけて学んでいってみたいと思います。
懇親会LT
懇親会では、飛び込みLTができる、ということでここ最近挑戦しているWebSocketをテーマにLTやってみました。
しかし結果は。。。
— 🍅増井将則(Nullable) (@masanori_msl) 2017年5月20日
5分を想定していたら、3分とそれより短かったことが最大の敗因ですが、
冒頭に余計な話をしすぎてしまったような気がします。
僕も開き直ってデモだけ楽しんでもらっておしまい!とした方が良かったな🤔
— 🍅増井将則(Nullable) (@masanori_msl) 2017年5月20日
バックでSpring boot使ってるんすよ、だけ言って。
でもデモのあたりで反応いただけたのは嬉しかったです😃🙏
今後、LTの時間がわからない場合も資料としてはともかく、話す内容としてはもう少し的をしぼって3分で収まるように作りたいと思います。
また、PCをインターネットに接続し忘れていたため、上記資料が途中から表示されなくなる、という問題もありました。
おそらくキャッシュが途中で消された?のかと思いますが、
ローカル環境にPDF自体は保存していたため、画面に映す資料としてはそちらを使うようにしたいと思います。
ちなみに、Demoで使用した寿司の3Dモデルは下記を使用しています。
https://www.assetstore.unity3d.com/jp/#!/content/37401
懇親会冒頭で寿司で盛り上がる(寿司スポンサーのLINEさん、ありがとうございます!)など、
不安もいっぱいでしたが暖かい反応をいただけて嬉しかったです。
おわりに
気にはなっていたものの、手が出せていなかったこと(例:Spring Security、OAuth2.0)についての話が聴けたので、
どんどん触っていきたいという気持ちが強まりました。
(から回ってる気はしますが)
次回は(採用される・されないはともかく)CfPを出せるよう、もっと突っ込んで調べたり、作ったりしていきたいと思います。
JJUG CCCは初の参加でしたが、今回からは託児所が導入されたり、
参加人数が1000人を超えるなど、ただただ感心するばかりです。
これも幹事の方々や、ボランティアスタッフの方々、スポンサーの方々のおかげだと思います。 本当にありがとうございました!
SpringでSTOMPを使わずにWebSocket
- はじめに
- 最小(と思う)構成
- メッセージの送受信
- WebSocketConfigurer
- WebSocketHandlerRegistry
- TextWebSocketHandler
- WebSocketHandler
- おわりに
- 参照
はじめに
前回はサーバー側をGolangにしていましたが、やっぱりSpring Bootでも実現したい!ということで、
今回はSpring BootでSTOMPなしでWebSocketを使い、Unityからアクセスできるようにしてみます。
なお今回の内容は、下記を参考にしています。
最小(と思う)構成
まずUnityからのアクセスによって接続を確立し、メッセージを受け取ってみます。
これをできるだけ少ないコードで再現しようとすると、こんな感じに。
build.gradle
buildscript { ext { springBootVersion = '1.5.3.RELEASE' } repositories { mavenCentral() } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") } } apply plugin: 'java' apply plugin: 'eclipse' apply plugin: 'org.springframework.boot' version = '0.0.1-SNAPSHOT' sourceCompatibility = 1.8 repositories { mavenCentral() } dependencies { compile('org.springframework.boot:spring-boot-starter-web') compile('org.springframework.boot:spring-boot-starter-websocket') testCompile('org.springframework.boot:spring-boot-starter-test') }
- spring-boot-starter-websocketを追加しています。
WebsocktestApplication.java
import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class WebsocktestApplication { public static void main(String[] args) { SpringApplication.run(WebsocktestApplication.class, args); } }
- Applicationクラスです。SpringInitializrで生成したクラスから特に変更はしていません。
WebSocketConfig.java
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(messageHandler(), "/ws"); } @Bean public WebSocketHandler messageHandler() { return new MessageHandler(); } }
STOMPを使った場合と同様に設定クラスを用意します。
詳細は後述しますが、指定したURL(localhost:8080/ws)にWebSocketを使ってアクセスがあった場合の登録処理を行います。
MessageHandler.java
import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; public class MessageHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 接続が確立されたら呼ばれる. } @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { // WebSocketクライアントからメッセージを受信した時に呼ばれる. } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 接続が切られたら呼ばれる. } }
メッセージの送受信
STOMPを使う場合は、接続確立時にSubscribeを行うことで、他のクライアントからメッセージを受信した場合にそれを受け取ることができていました。
ただ今回はその方法が使用できないため、接続確立時にWebSocketSessionの情報を保持しておき、
メッセージを受け取ったらメッセージの送信者以外にメッセージを送信するようにしてみます。
※下記よりスマートな方法があるような気はしますが、記事投稿時点で他に思いつきませんでしたorz
MessageHandler.java
import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.util.ArrayList; public class MessageHandler extends TextWebSocketHandler { private ArrayList< WebSocketSession > users; public MessageHandler(){ users = new ArrayList<>(); } @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 接続確立時、配列にWebSocketSessionの情報を追加. if(users.stream() .noneMatch(user -> user.getId().equals(session.getId()))){ users.add(session); } } @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { // メッセージを受け取ったら送信元以外にメッセージを送る. users.stream() .filter(user -> !user.getId().equals(session.getId())) .forEach(user -> { try{ user.sendMessage(message); } catch (IOException ex){ System.out.println(ex.getLocalizedMessage()); } }); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 接続が切れたら配列から削除. users.stream() .filter(user -> user.getId().equals(session.getId())) .findFirst() .ifPresent(user -> users.remove(user)); } }
これでとりあえず複数アプリからlocalhost:8080/wsに接続すると、
メッセージを送り合うことができるようになりました。
次は設定クラスが継承しているWebSocketConfigurer.javaと、イベントハンドラーのTextWebSocketHandler.javaの処理を追ってみることにします。
WebSocketConfigurer
まず設定クラスから追いかけてみます。
WebSocketConfig.java
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(messageHandler(), "/ws"); } @Bean public WebSocketHandler messageHandler() { return new MessageHandler(); } }
ここでは後述するハンドラー(WebSocketHandler)の登録と、Bean定義(DIコンテナに追加する)を行っています。
「registry.addHandler(messageHandler(), “/ws”)」で、
localhost:8080/wsにアクセスした時のハンドラーを登録しています。
またBean定義についてですが、今回DIを使っていないので、下記のようにしても動いたりします。
@Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new MessageHandler(), "/ws"); }
このクラスが継承しているWebSocketConfigurer.javaは、ハンドラー登録の関数(registerWebSocketHandlers)だけを持つインターフェイスです。
では、この引数であるWebSocketHandlerRegistryを追ってみましょう。
WebSocketHandlerRegistry
WebSocketHandlerRegistry.java
package org.springframework.web.socket.config.annotation; import org.springframework.web.socket.WebSocketHandler; public interface WebSocketHandlerRegistry { WebSocketHandlerRegistration addHandler(WebSocketHandler webSocketHandler, String... paths); }
関数addHandlerの戻り値になっているWebSocketHandlerRegistryを見てみます。
package org.springframework.web.socket.config.annotation; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.server.HandshakeHandler; import org.springframework.web.socket.server.HandshakeInterceptor; public interface WebSocketHandlerRegistration { // ハンドラーの追加.第二引数でアクセスポイントを指定. WebSocketHandlerRegistration addHandler(WebSocketHandler handler, String... paths); // ハンドシェイク(WebSocketの接続開始時に行う通信)に対するHandlerのセット. WebSocketHandlerRegistration setHandshakeHandler(HandshakeHandler handshakeHandler); // ハンドシェイクの前後に処理を追加するためのインターセプター. WebSocketHandlerRegistration addInterceptors(HandshakeInterceptor... interceptors); // Originヘッダー値の設定. WebSocketHandlerRegistration setAllowedOrigins(String... origins); // Sock.jsを使用する. SockJsServiceRegistration withSockJS(); }
ということで、設定クラスでは主にハンドラーの設定を行っています。
TextWebSocketHandler
ではそのハンドラーについて。
まずはもとのコードから(Sessionの保持やメッセージの送信などは省略します)。
MessageHandler.java
import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; public class MessageHandler extends TextWebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 接続が確立されたら呼ばれる. } @Override public void handleTextMessage(WebSocketSession session, TextMessage message) { // WebSocketクライアントからメッセージを受信した時に呼ばれる. } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 接続が切られたら呼ばれる. } }
次に継承しているTextWebSocketHandler.javaを見てみます。
TextWebSocketHandler.java
package org.springframework.web.socket.handler; import java.io.IOException; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketSession; public class TextWebSocketHandler extends AbstractWebSocketHandler { @Override protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) { try { session.close(CloseStatus.NOT_ACCEPTABLE.withReason("Binary messages not supported")); } catch (IOException ex) { // ignore } } }
ここではバイナリメッセージが送信された場合に、そのセッションを閉じています。
このようにしている理由は、WebSocketHandlerが文字列にしか対応していないため、ということのようです。
最初から実装していないと、バイナリメッセージが送信された場合に受信できずエラーになる、ということなのでしょうか。
※2017.05.17更新
バイナリメッセージが送信された時にセッションを閉じるのは、
このクラスがTextWebSocketHandlerの名前の通りテキストデータをやりとりするためのものなので、
それ以外のデータは受け付けませんよ、ということのようです。
失礼しましたorz
続けてAbstractWebSocketHandler.javaを見てみます。
AbstractWebSocketHandler.java
package org.springframework.web.socket.handler; import org.springframework.web.socket.BinaryMessage; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.PongMessage; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketMessage; import org.springframework.web.socket.WebSocketSession; public abstract class AbstractWebSocketHandler implements WebSocketHandler { @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { } @Override public void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception { if (message instanceof TextMessage) { handleTextMessage(session, (TextMessage) message); } else if (message instanceof BinaryMessage) { handleBinaryMessage(session, (BinaryMessage) message); } else if (message instanceof PongMessage) { handlePongMessage(session, (PongMessage) message); } else { throw new IllegalStateException("Unexpected WebSocket message type: " + message); } } protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { } protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception { } protected void handlePongMessage(WebSocketSession session, PongMessage message) throws Exception { } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { } @Override public boolean supportsPartialMessages() { return false; } }
ほとんどカラですが、handleMessageで受け取ったメッセージの型に合わせて処理を振り分けていますね。
では大本のWebSocketHandler.javaです。
WebSocketHandler
WebSocketHandler.java
package org.springframework.web.socket; public interface WebSocketHandler { // 接続確立後のイベント. void afterConnectionEstablished(WebSocketSession session) throws Exception; // メッセージを受け取ったときのイベント. void handleMessage(WebSocketSession session, WebSocketMessage message) throws Exception; // メッセージのやり取り中のエラーをハンドリングする. void handleTransportError(WebSocketSession session, Throwable exception) throws Exception; // 接続を切ったときのイベント. void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception; // サイズが大きすぎる場合などに分割されたメッセージを扱うかどうか. boolean supportsPartialMessages(); }
おわりに
Golangもそうですが、シンプルなコードで通信ができてしまうのはすごいと思いました(小並感)。
次回は下記のような部分をなんとかしたいと思います。 * セッション情報をハンドラークラスから分離する * Webブラウザから特定のページにアクセスしたときに、今接続しているアプリの情報(IDや数など)や送信しているメッセージの表示を行う
あとはUnity側から送信するメッセージの量をもっと増やした場合でも、問題が起きないかなども気になるところですね。
参照
- 26. WebSocket Support - Spring
- Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発
- WebSocketHandler (Spring Framework 4.3.8.RELEASE API) - Spring
- WebSocketHandler (Spring Framework 4.3.8.RELEASE API) - Spring
- TextWebSocketHandler (Spring Framework 4.3.8.RELEASE API) - Spring
- WebSocketConfigurer (Spring Framework 4.3.8.RELEASE API) - Spring
UnityでWebSocketを使ってみたい
はじめに
前回に引き続きWebSocketについてのお話。
ですが、今回はUnityからwebsocket-sharpというライブラリを使ってWebSocket Clientとしてアクセスします。
そして、アクセスする先はgorilla/websocketのExamplesにあるチャットです。
Springはどうしたんじゃいという話なのですが、C#でSTOMPを使う方法がわからなかったため、
とりあえず今回はGolangのものを使用することにしました。
こちらについては問題が解決すれば別途ブログにまとめたいと思います。
websocket-sharp
まずUnityからWebsocketでアクセスするのに利用する、websocket-sharpの準備です。
Unityから呼び出せるようにするために、dllファイルを作成する必要があります。
上記GitHubのReadmeの通りなのですが、一応手順を載せておきます。
(Jetbrains Riderで実行しました)
- プロジェクトをダウンロードしてソリューションファイルを開きます。
- ソリューションが5つ(websocket-sharpとExample4つ)あり、その中の「websocket-sharp」の上で右クリック->「Build selected projects」でビルドします。
ビルドに成功したら、websocket-sharp > websocket-sharp > bin > Debug(or Release)にwebsocket-sharp.dllが出力されるため、UnityプロジェクトのAssets以下に置きます。
Step2で、プロジェクト全体に対してビルドすると、Exampleのどれかでエラーになります。
で、Websocketでアクセスするコードはこんな感じです。
WebsocketAccessor.cs
using System; using System.Collections.Generic; using UnityEngine; using WebSocketSharp; public class WebsocketAccessor : MonoBehaviour { private void Start() { // サーバー側ではlocalhost:8088/wsにアクセスした時に登録処理を行う. using(WebSocket ws = new WebSocket("ws://localhost:8088/ws")){ // 接続開始時のイベント. ws.OnOpen += (sender, e) => { Debug.Log("Opended"); }; // メッセージ受信時のイベント. ws.OnMessage += (sender, e) => { Debug.Log("Received " + e.Data); }; // 接続. ws.Connect (); // メッセージ送信. ws.Send ("世界さん、チーッす!"); } } }
パスワードもないものにアクセスするだけとはいえ、シンプルですね。
サーバー側ではメッセージの送受信をbyte型のスライスで行っていますが、
Unity側からはstring型のデータを渡すことができます。
また、このUnityプロジェクトをビルドして複数起動すれば、Unityアプリ同士でメッセージの送受信も行うことができます。便利!
Json
さて、上記のように単純にメッセージを送り合うだけなら問題がないのですが、
例えば3Dモデルの座標値を送りたい場合。
前述の通りメッセージとしてstring型のデータを扱えるため、JsonUtilityを使ってJson形式に変換し、それを送信してみたいと思います。
まずJsonに変換するデータを格納するためのクラスを用意します。
ObjectStatus.cs
using System; [Serializable] public class ObjectStatus { public float PositionX; public float PositionY; public float PositionZ; }
- クラス名については気にしない方向でお願いいたします(白目)
で、入れ物が用意できればあとはデータを詰めてJsonに変換するだけです。
WebsocketAccessor.cs
using System; using System.Collections.Generic; using UnityEngine; using WebSocketSharp; public class WebsocketAccessor : MonoBehaviour { public GameObject TargetModel; private WebSocket ws; private ObjectStatus objectStatus; private void Start() { objectStatus = new ObjectStatus(); ws = new WebSocket("ws://localhost:8088/ws"); ws.OnOpen += (sender, e) => { Debug.Log("Opended"); }; ws.OnMessage += (sender, e) => { Debug.Log("Received " + e.Data); }; ws.Connect (); } private void Update() { if (Input.GetMouseButtonUp(1)) { // データをクラスにセット. objectStatus.PositionX = TargetModel.transform.localPosition.x; objectStatus.PositionY = TargetModel.transform.localPosition.y; objectStatus.PositionZ = TargetModel.transform.localPosition.z; // セットしたクラスを使ってJson形式に変換. var json = JsonUtility.ToJson(objectStatus); // 変換したデータを送信する. ws.Send (json); } } private void OnDestroy() { // 接続を切る. ws.Close(); } }
- 今回は右クリック時にメッセージを送信しています。
送信したデータは下記のような内容となります。
特に送信するデータが多い場合など、要素名は短くしておいた方が良いかもしれません。
{"PositionX":1.84,"PositionY":1.28,"PositionZ":0.0}
おわりに
今回はとりあえずサンプルを試しただけで、あまり中身に触れられませんでした。
ということで、次回あたりでもう少し触れてみたいと思います。
また、サーバー側の処理についても少し追いかけてみたいと思います。
参照
SpringBootでWebSocketを使ってみたい(Sample Backend編)
- はじめに
- Keywords
- クラス構成
- GreetingController
- WebSocketConfig
- AbstractWebSocketMessageBrokerConfigurer
- おわりに
- 参照
はじめに
チャットのように、Webサーバーを通してデータの受け渡しをリアルタイムで実施するための技術にWebSocketがあります。
今回はこれをSpringBootから利用する方法として紹介されていた下記の内容について調べつつ、
いじってみたりすることにします。
Keywords
WebSocket
まずWebSocketについてですが、超乱雑にまとめると
- 最初にクライアント側とサーバー側を連携し、その後は接続・切断をせず小さいオーバーヘッドでやり取りを行う
- クライアント側からだけでなくサーバー側からもデータ(メッセージ)のやりとりをリクエストできる
といったところでしょうか。
頻繁なメッセージのやりとりが必要なチャットなどを作るときに利用されます。
詳しくは下記のようなページをご覧ください。(内容が間違っていたり、あまりにも説明が不足していることがわかれば後日修正します)
- Windows 8 のネットワーク接続 - Windows 8 と WebSocket プロトコル - MSDN
- 26. WebSocket Support - Spring
- RFC 6455 - The WebSocket Protocol (日本語訳)
- WebSocketについて調べてみた。 - Nao Minami’s Blog
今回のサンプルではsockjsを利用しています。
STOMP
WebSocketでクライアント側とサーバー側でやりとりをするためには、
お互いに共通した方法(プロトコル)を持っている必要があります。
サンプルでは、このためのプロトコルとしてSTOMP(Simple (or Streaming) Text Orientated Messaging Protocol)を使用しています。
正式名称の通り、テキストベースのメッセージでやりとりを行います。
メッセージのやりとりの流れは最初にSubscribeを行い、
更新があればそれを受け取る、ということのようです。
このSTOMPをWebSocket上で使用するため、STOMP.jsを利用しています。
一点気がかりなのは、GitHubを見るとメンテナンスが止まってしまっていること。
みなさんどう対処しているのか(´・ω・`)
。。。とりあえず今回はStomp.jsを使用することとします。
クラス構成
上記のサンプルに含まれるクラスは以下の通りです。
- src
- main.java.hello
この内「Application.java」はSpringBootの開始を担うメインクラス、「Greeting.java」「HelloMessage.java」はデータを保持するモデルクラスです。
ここからは「GreetingController.java」「WebSocketConfig.java」について追いかけてみます。
GreetingController
コントローラークラスです。
GreetingController.java
import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.SendTo; import org.springframework.stereotype.Controller; @Controller public class GreetingController { @MessageMapping("/hello") @SendTo("/topic/greetings") public Greeting greeting(HelloMessage message) throws Exception { // 1秒待ってから応答する. Thread.sleep(1000); // メッセージ内容を"/topic/greetings"のSubscriberに渡す. return new Greeting("Hello, " + message.getName() + "!"); } }
- @Controllerアノテーションを付与することで、通常のルーティング処理に加えてメッセージのルーティングも行うことができます。
- @MessageMappingアノテーションを付与することで、今回は「/hello」にメッセージが送信されたときに関数「greeting(HelloMessage message)」が呼ばれます。
このときメッセージの内容は、「HelloMessage.java」に格納された形で引数として渡されます。 - @SendToアノテーションを付与することで、戻り値であるメッセージの内容を「/topic/greetings」をSubscribeしているユーザーに伝えることができます。
- 「Thread.sleep(1000);」は無くても動作します。
なおマッピングできるパスの形式は、「/hello」の他「/hello.message.*」や「/hello.message.{goodmorning}」といったものも指定できるようです(MessageMapping.classより)。
WebSocketConfig
設定クラスです。
WebSocketConfig.java
import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic"); config.setApplicationDestinationPrefixes("/app"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/gs-guide-websocket").withSockJS(); } }
- @EnableWebSocketMessageBrokerアノテーションを付与することで、WebSocketのメッセージのやり取りを有効にします。
ここで実装している内容は2つです。
- MessageBrokerの設定
- STOMPのエンドポイントの登録
1. MessageBrokerの設定
メッセージのやりとりを仲介するMessageBrokerの設定を行います。
config.enableSimpleBroker(“/topic”);
「/topic」以下のURL(GreetingController.javaのメッセージ送信先である「/topic/greetings」)でメッセージを渡せるようにします。
メッセージを受け取ったら、あらかじめSubscribeしていたユーザーにメッセージが渡されます。
config.setApplicationDestinationPrefixes(“/app”);
「/app」以下のURLにメッセージを送ると、コントローラークラス(GreetingController.java)が呼ばれるようになります。
今回はクライアント側から「/app/hello」にメッセージを送信すると、
GreetingController.javaの「greeting(HelloMessage message)」が呼ばれます。
2. STOMPのエンドポイントの登録
sockjsを使ってWebSocketの接続処理を行うエンドポイントの登録を行います。
「withSockJS()」は今回sockjsを使っているため必要なのですが、
sockjsを使わない場合にWebSocketを使う処理が行えるのか、可能であればどのようにするのかは気になるところです。
AbstractWebSocketMessageBrokerConfigurer
設定クラス(WebSocketConfig.java)で継承しているAbstractWebSocketMessageBrokerConfigurerではどのようなことをしているのか、少し見てみることにしました。
AbstractWebSocketMessageBrokerConfigurer.java
package org.springframework.web.socket.config.annotation; import java.util.List; import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; public abstract class AbstractWebSocketMessageBrokerConfigurer implements WebSocketMessageBrokerConfigurer { @Override public void configureWebSocketTransport(WebSocketTransportRegistration registration) { } @Override public void configureClientInboundChannel(ChannelRegistration registration) { } @Override public void configureClientOutboundChannel(ChannelRegistration registration) { } @Override public boolean configureMessageConverters(ListmessageConverters) { return true; } @Override public void addArgumentResolvers(List argumentResolvers) { } @Override public void addReturnValueHandlers(List returnValueHandlers) { } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { } }
※コメント、Javadocsは省略しています。
WebSocketMessageBrokerConfigurer.javaのメソッドをOverrideしていますが、
中身はすべて空になっています。
ドキュメントを見ると、WebSocketMessageBrokerConfigurer.javaを実装しやすくするため、
オプショナルな関数を空で実装している抽象クラスのようです。
設定クラスで実装していた「configureMessageBroker(MessageBrokerRegistry registry)」も含まれているため、
実装が必須な関数というのは、STOMPのエンドポイントの登録を行う「registerStompEndpoints(StompEndpointRegistry registry)」だけのようですね。
では、WebSocketMessageBrokerConfigurer.javaも見てみます。
WebSocketMessageBrokerConfigurer.java
package org.springframework.web.socket.config.annotation; import java.util.List; import org.springframework.messaging.converter.MessageConverter; import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler; import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; public interface WebSocketMessageBrokerConfigurer { void registerStompEndpoints(StompEndpointRegistry registry); void configureWebSocketTransport(WebSocketTransportRegistration registry); void configureClientInboundChannel(ChannelRegistration registration); void configureClientOutboundChannel(ChannelRegistration registration); void addArgumentResolvers(ListargumentResolvers); void addReturnValueHandlers(List returnValueHandlers); boolean configureMessageConverters(List messageConverters); void configureMessageBroker(MessageBrokerRegistry registry); }
※コメント、Javadocsは省略しています。
オプショナルな関数の内容は下記を参照していただきたいところですが、
WebSocketでのメッセージの送受信を行う関数(MessageChannel)の設定などが行えるため、必要に応じてOverrideすることになりそうです。
なお、interfaceで共通の関数を指定して、共通の処理を抽象クラスで実装し、個別の処理を抽象クラスを継承したクラスで実装する、
という流れはまさにJava本格入門で読んだ内容だな〜、などと思いながら見ていました。
おわりに
細かく追ってはみたつもりですが、全体的にまだフワフワしているため、
実際にサンプルを作りながらもう少し突っ込んで触る必要がありそうです。
次回はクライアント側を追いかけてみます。
参照
Spring
- Getting Started · Using WebSocket to build an interactive web application - Spring
- Spring 4.3 WebSocket関連の主な変更点(+簡易アプリ作成!!) - Qiita
- 26. WebSocket Support - Spring
- Spring Bootでチャットツールを作りながらWebの仕組みを理解しよう! - SlideShare
- 6. STOMPを使ってみる — Spring Bootキャンプ ハンズオン資料 1.0.0-SNAPSHOT ドキュメント
WebSocket
- Windows 8 のネットワーク接続 - Windows 8 と WebSocket プロトコル - MSDN
- RFC 6455 - The WebSocket Protocol (日本語訳)
- WebSocketについて調べてみた。 - Nao Minami’s Blog
- SockJS-client - sockjs - GitHub