Grails/Vue.jsでTodoアプリ(認証) 1/2
これはG* Advent Calendar 2017の9日目の記事です。
この記事は、以下の5つの投稿で成り立っています。
Grails/Vue.jsでTodoアプリ(環境構築)
Grails/Vue.jsでTodoアプリ(CRUD)
Grails/Vue.jsでTodoアプリ(認証) 1/2
Grails/Vue.jsでTodoアプリ(認証) 2/2
Grails/Vue.jsでTodoアプリ(Deploy as war)
初めに
昨日の記事で実際にRESTful APIを使ってGrailsとVue.js/axiosでCRUD操作を実装しました。
こうなるとやはりログイン処理を実装したくなりますね。
ということで今回は、GrailsにRESTful APIで利用可能な認証機構を追加していきます。
前提条件
Grailsで作成したアプリーションのディレクトリは今後$GRAILS_PROJECT
とし、Vue.jsのアプリケーションのディレクトリはその直下なで$VUE_PROJECT
と記述します。
私の環境では、$GRAILS_PROJECT
は/home/koji/work/grails/gvtodo
で、$VUE_PROJECT
は$GRAILS_PROJECT/frontend
になっています。
準備
プラグインの追加
$GRAILS_PROJECT/build.gradle
のdependencies
に以下を追記します。
なお、下記のプラグインは2017/11/10の時点で最新のものです。
compile 'org.grails.plugins:spring-security-core:3.2.0' compile "org.grails.plugins:spring-security-rest:2.0.0.M2"
コレで、一旦Grailsを完全に終了して、インタラクティブモードからも抜けます。
そして、再度grails
コマンドを実行すれば、自動的に上記2プラグインがダウンロード、セットアップされます。
sprng-security-core
が、Grailsに認証機構を追加するプラグインで、spring-security-rest
がさらにその上でRESTful API経由で認証出来るようにするプラグインとなります。
認証用ドメインと設定ファイルの作成
当然ログインするユーザのIDとパスワード(とモロモロ)を管理する必要が有ります。
これらはSpringSecurityCoreプラグインが提供してくれているs2-quickstart
コマンド一発で生成できます。
以下のようにコマンドを実行して、認証用のユーザ情報を保持するドメインなどを作成します。
grailsのインタラクティブモードに入ったら、s2-quickstart gvtodo AuthUser AuthRole
を実行してください。
grails> s2-quickstart gvtodo AuthUser AuthRole | Creating User class 'AuthUser' and Role class 'AuthRole' in package 'gvtodo' | Rendered template PersonWithoutInjection.groovy.template to destination grails-app/domain/gvtodo/AuthUser.groovy | Rendered template PersonPasswordEncoderListener.groovy.template to destination src/main/groovy/gvtodo/AuthUserPasswordEncoderListener.groovy | Rendered template Authority.groovy.template to destination grails-app/domain/gvtodo/AuthRole.groovy | Rendered template PersonAuthority.groovy.template to destination grails-app/domain/gvtodo/AuthUserAuthRole.groovy | ************************************************************ * Created security-related domain classes. Your * * grails-app/conf/application.groovy has been updated with * * the class names of the configured domain classes; * * please verify that the values are correct. * ************************************************************ grails>
書式は、s2-quickstart パッケージ名 認証ユーザドメイン名 認証ロール名
となります。
認証ユーザとロールの作成(デフォルトのAdministrator)
では、テスト用に初期ユーザを登録します。
Bootstrap.groovy
を以下のようにします。
package gvtodo class BootStrap { def init = { servletContext -> def userRole = new AuthRole(authority: 'ROLE_USER').save() def adminRole = new AuthRole(authority: 'ROLE_ADMIN').save() def admin = new AuthUser(username: 'admin', password: 'password').save() AuthUserAuthRole.create admin, adminRole AuthUserAuthRole.withSession { it.flush() it.clear() } } def destroy = { } }
これでGrails起動時に常にこのログイン情報(ロールとユーザ)が作成されるようになりました。
認証情報の設定
Grails3がデフォルトで持っている設定ファイルはapplication.yml
ですが、SpringSecurityCoreによって古い設定ファイル方式のapplication.groovy
が生成されて、デフォルトの設定が自動的に記述されています。
デフォルトでは以下のようになっています。
// Added by the Spring Security Core plugin: grails.plugin.springsecurity.userLookup.userDomainClassName = 'gvtodo.AuthUser' grails.plugin.springsecurity.userLookup.authorityJoinClassName = 'gvtodo.AuthUserAuthRole' grails.plugin.springsecurity.authority.className = 'gvtodo.AuthRole' grails.plugin.springsecurity.controllerAnnotations.staticRules = [ [pattern: '/', access: ['permitAll']], [pattern: '/error', access: ['permitAll']], [pattern: '/index', access: ['permitAll']], [pattern: '/index.gsp', access: ['permitAll']], [pattern: '/shutdown', access: ['permitAll']], [pattern: '/assets/**', access: ['permitAll']], [pattern: '/**/js/**', access: ['permitAll']], [pattern: '/**/css/**', access: ['permitAll']], [pattern: '/**/images/**', access: ['permitAll']], [pattern: '/**/favicon.ico', access: ['permitAll']] ] grails.plugin.springsecurity.filterChain.chainMap = [ [pattern: '/assets/**', filters: 'none'], [pattern: '/**/js/**', filters: 'none'], [pattern: '/**/css/**', filters: 'none'], [pattern: '/**/images/**', filters: 'none'], [pattern: '/**/favicon.ico', filters: 'none'], [pattern: '/**', filters: 'JOINED_FILTERS'] ]
コレを以下のように修正します。
// Added by the Spring Security Core plugin: grails.plugin.springsecurity.userLookup.userDomainClassName = 'gvtodo.AuthUser' grails.plugin.springsecurity.userLookup.authorityJoinClassName = 'gvtodo.AuthUserAuthRole' grails.plugin.springsecurity.authority.className = 'gvtodo.AuthRole' grails.plugin.springsecurity.logout.postOnly = false grails.plugin.springsecurity.securityConfigType = "InterceptUrlMap" grails.plugin.springsecurity.interceptUrlMap = [ // for SpringSecurityREST [pattern: '/oauth/**', access: ['permitAll']], [pattern: '/api/**', access: ['ROLE_USER', 'ROLE_ADMIN']], [pattern: '/manual/**', access: ['ROLE_USER', 'ROLE_ADMIN']], // for /dbconsole [pattern: '/login/**', access: ['permitAll']], [pattern: '/dbconsole/**', access: ['ROLE_ADMIN']], [pattern: '/**', access: ['ROLE_USER', 'ROLE_ADMIN']] ] grails.plugin.springsecurity.filterChain.chainMap = [ //Stateless chain [pattern: '/api/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], [pattern: '/manual/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], [pattern: '/assets/**', filters: 'none'], [pattern: '/**/js/**', filters: 'none'], [pattern: '/**/css/**', filters: 'none'], [pattern: '/**/images/**', filters: 'none'], [pattern: '/**/favicon.ico', filters: 'none'], [pattern: '/**', filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter'] ]
変更点の概要は大まかに以下のとおりです
- ログアウトがデフォルトだとPOST限定なので、GETなどでも行えるようにする
- 権限の確認方法をアノテーションベースからURLベースに変更
- SpringSecurityRESTの設定を追加
- SpringSecurityCoreがどのURLに対してどのロールを要求するのか設定
grails.plugin.springsecurity.filterChain.chainMap
これはSpringSecurityREST
の為に追加しました。
コード内にJOINED_FILTERS
という項目が現れていますが、コレはどういった認証方法(Filter)をpattern(URI)
に対して許可/利用するか、というものになります。
すでにSpringSecurityCore
によってJOINED_FILTERS
には複数の認証方法が定義されていますが、SpringSecurityREST
によってさらにrestTokenValidationFilter
、restExceptionTranslationFilter
が追加されています。
つまり、以下の2行では
[pattern: '/api/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'], [pattern: '/manual/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'],
/api/**
、/manual/**
へのアクセスの場合、
anonymousAuthenticationFilter
、
exceptionTranslationFilter
、
authenticationProcessingFilter
、
securityContextPersistenceFilter
、
rememberMeAuthenticationFilter
、
を認証方法(Filter)から省いています。
そして、すでに述べた通り、restTokenValidationFilter
、restExceptionTranslationFilter
の2つのSpringSecuiryRESTによって追加されたFilterは省いていないので、このFilterによってRESTful APIでの認証が可能になる、ということになります。
なお、それ以外のURLに関しては最後の行の、
[pattern: '/**',filters: 'JOINED_FILTERS,-restTokenValidationFilter,-restExceptionTranslationFilter']
で指定されている通り、SpringSecurityREST
で追加されたFilterを省いたモノすべて、つまり通常のSpringSecurityCore
プラグインでの認証方法、ということになっています。
grails.plugin.springsecurity.interceptUrlMap
この設定はSpringSecurityCore
の設定で、どのURLがどのような認証/認可を行うのかというルールを指定します。
今回特に重要なのは、
// for SpringSecurityREST [pattern: '/oauth/**', access: ['permitAll']], [pattern: '/api/**', access: ['ROLE_USER', 'ROLE_ADMIN']], [pattern: '/manual/**', access: ['ROLE_USER', 'ROLE_ADMIN']],
の部分です。
/oauth/**
には、アクセストークンの再発行の為にアクセスしますので、アクセストークン無しでアクセスできるようにpermitAll
にしてあります。(このアクセストークンの再発行は明日の記事にて説明します。)
/api/**
、/manual/**
に関しては、すでにログインしていて、ROLE_USER
かROLE_ADMIN
のロールを持っているユーザのみアクセス可、としています。
ココで1点分かりづらい部分が有るのですが、/api/**
には、SpringSecurityRESTによって、
login
logout
validate
の3つのURLがGrailsに追加されています。
しかし、/api/**
にアクセスするのにROLE_USER
かROLE_ADMIN
が無いといけないという指定をしているので、そもそもログインしたいが為に/api/login
にアクセスしてもエラーになっちゃうのでは?と言う疑問が残ります。
この部分はSpringSecurityREST
がうまいことやってくれていて、/api/login
へのアクセスは例外としてOK、という処理になっているようです。
実際に以下のように各URLにcurlでアクセスすると、基本的に401エラーが返ってくるけど、/api/login
だけはエラーになりませんでした。
[koji:~]$ curl http://localhost:8080/api/ {"timestamp":1512134061526,"status":401,"error":"Unauthorized","message":"No message available","path":"/api/"}% [koji:~]$ curl http://localhost:8080/api/logout {"timestamp":1512134065363,"status":401,"error":"Unauthorized","message":"No message available","path":"/api/logout"}% [koji:~]$ curl http://localhost:8080/api/validate {"timestamp":1512134068645,"status":401,"error":"Unauthorized","message":"No message available","path":"/api/validate"}% [koji:~]$ curl http://localhost:8080/api/login [koji:~]$
コレで基本的に設定は完了です。Grailsをrun-appで起動して、実際に直接Grailsにアクセス(http://localhost:8080)してみるとログイン画面が表示されると思います。
ログイン処理(Vue.js/axios)
さて、それでは実際にログイン出来る用にアプリケーションを改良していきます。
ログイン画面
新たにログイン画面を作成します。
`$VUE_PROJECT/src/components/Login.vue
<template> <section class="section"> <div class="container is-fluid"> <input v-model="username" class="input" placeholder="Login id" v-bind:class="{'is-danger': hasError}"/><br> <input v-model="password" class="input" type="password" placeholder="password" v-bind:class="{'is-danger': hasError}" @keyup.enter="login"/><br> <br> <button @click="login" class="button is-primary">Login</button><br /> </div> </section> </template> <script> import axios from 'axios' import UserInfoRepository from '@/UserInfoRepository' export default { data: function () { return { username: '', password: '', hasError: false } }, methods: { login: function () { // SpringSecurityRESTがある前提。 // で、以下のURLにログイン情報を送信すると、認証情報が返る。 const url = `http://localhost:8080/api/login` axios.post(url, this.$data) .then((response) => { UserInfoRepository.save(response.data) this.$router.push({name: 'ToDoIndex'}) }) .catch((error) => { console.log(error.response) this.hasError = true }) } } } </script>
ログインした情報を保持する
ユーザ情報の取り扱い
ログインしたユーザの情報を取り扱うsrc/UserInfo.js
を作成します。
$VUE_PROJECT/src/UserInfo.js
export default class UserInfo { constructor (rawJson) { this.json = rawJson } isAdmin () { return this.json.roles.some((role) => { return role === 'ROLE_ADMIN' }) } getUserName () { return this.json.username } getAccessToken () { return this.json.access_token } getRefreshToken () { return this.json.refresh_token } generateAuthHeader () { return { 'Authorization': `Bearer ${this.getAccessToken()}` } } }
ログインしたユーザ情報の保存
今回、フロントエンドで、サーバから受け取った認証情報はlocalStoageに保存するようにします。
ソレを扱うためのsrc/UserInfoRepository.js
を作成します。
$VUE_PROJECT/src/UserInfoRepository
import UserInfo from '@/UserInfo' export default class UserInfoRepository { static save (userInfo) { localStorage.setItem('userInfo', JSON.stringify(userInfo)) } static logout () { localStorage.removeItem('userInfo') } static get () { const userString = localStorage.getItem('userInfo') if (userString) { const userJson = JSON.parse(userString) return new UserInfo(userJson) } else { return null } } }
ログインを必須にする
そして全てのリクエスで必ず、ログインしているかどうかをチェックするように、src/router/index.js
を以下のように修正します。
$VUE_PROJECT/src/router/index.js
import Vue from 'vue' import Router from 'vue-router' import ToDoIndex from '@/components/todo/Index' import Login from '@/components/Login' import UserInfoRepository from '@/UserInfoRepository' Vue.use(Router) const vr = new Router({ routes: [ { path: '/', name: 'ToDoIndex', component: ToDoIndex }, { path: '/login', name: 'Login', component: Login } ] }) vr.beforeEach((to, from, next) => { const toLoginPage = (err) => { console.log(err) next({ path: '/login', query: { redirect: to.fullPath } }) } if (to.name === 'Login') { next() return } const userInfo = UserInfoRepository.get() if (userInfo) { next() } else { toLoginPage() } }) export default vr
APIへのアクセスに認証情報(access_token)を付与する
最後に、ToDoをRESTful APIを利用して操作している部分で、認証用のトークンをヘッダーにセットするように変更します。
SpringSecurityRESTでは、デフォルトでは認証情報をHTTPヘッダーのAuthritzation
からBearer認証スキームを前提として抜き出します。
つまり、HTTPリクエストヘッダーのAuthaorizationは以下のような形になります。
Authorization: Bearer {ログイン時にSpringSecurityRESTが返してくれたaccess_token}
この辺りの詳細はRFCを参照してください。
The OAuth 2.0 Authorization Framework: Bearer Token Usage(日本語)
$VUE_PROJECT/src/components/todo/Index.vue
<template> <div> <h1>ToDoリスト</h1> <input v-model="title"> <button @click="add">add</button> <ul> <li v-for="todo in todoList"> <input class="todo-title" v-bind:value="todo.title" @keyup.enter="edit($event, todo.id)" @blur="edit($event, todo.id)"> <button @click="remove(todo.id)">del</button> </li> </ul> </div> </template> <script> import axios from 'axios' import UserInfoRepository from '@/UserInfoRepository' let errorFunc = (error) => { alert(`HTTP Status: ${error.response.status}, [${error.response.data.message}]`) } // const baseURL = 'http://localhost:8080/todo' const baseURL = 'http://localhost:8080/manual/items' export default { data: function () { return { title: '', todoList: [] } }, mounted: function () { this.list() }, methods: { list: function () { axios.get(`${baseURL}`, {headers: UserInfoRepository.get().generateAuthHeader()}) .then((response) => { this.todoList = response.data }).catch(errorFunc) }, add: function () { axios.post(`${baseURL}`, { title: this.title }, {headers: UserInfoRepository.get().generateAuthHeader()}) .then((response) => { this.title = '' this.list() }) .catch(errorFunc) }, edit: function (event, id) { axios.put(`${baseURL}/${id}`, { title: event.target.value }, {headers: UserInfoRepository.get().generateAuthHeader()}) .then((response) => { this.list() }) .catch(errorFunc) }, remove: function (id) { axios.delete(`${baseURL}/${id}`, {headers: UserInfoRepository.get().generateAuthHeader()}) .then((response) => { this.list() }) .catch(errorFunc) } } } </script> <style> .todo-title { border: none; } </style>
コレでアプリケーションに認証を機能を付けることが出来ました!
GrailsとVueアプリケーションを起動してから、実際にhttp://localhost:8081にアクセスしてみてください。ちゃんとログイン画面が表示されて、ログイン後操作できることを確認できると思います。
まとめ
とても簡単にRESTful APIベースの認証機構を追加することが出来ました。
さて、今回実装してはっきり解ると思いますが、トークン(access_tokenかrefresh_token)が第3者に漏れるとイコールアカウントを乗っ取られます。
ただのHTTPヘッダーにトークンを埋め込むわけですので、非SSL環境では絶対に利用するべきではありません!
幸いなことに、現在はLet's Encryptのように無料で簡単にSSLを導入することが出来ます。必ずSSL経由で認証を行うようにしましょう!
参考
Grails/Vue.jsでTodoアプリ(CRUD)
これはG* Advent Calendar 2017の8日目の記事です。
この記事は、以下の5つの投稿で成り立っています。
Grails/Vue.jsでTodoアプリ(環境構築)
Grails/Vue.jsでTodoアプリ(CRUD)
Grails/Vue.jsでTodoアプリ(認証) 1/2
Grails/Vue.jsでTodoアプリ(認証) 2/2
Grails/Vue.jsでTodoアプリ(Deploy as war)
初めに
昨日の記事でGrailsでRESTful APIサーバを構築、そしてフロントエンド用のVue.jsアプリケーションを作成しました。
今日は実際にGrailsとVue/axiosを連携させてToDoアプリケーションを作成していきます。
前提条件
Grailsで作成したアプリーションのディレクトリは今後$GRAILS_PROJECT
とし、Vue.jsのアプリケーションのディレクトリはその直下なで$VUE_PROJECT
と記述します。
私の環境では、$GRAILS_PROJECT
は/home/koji/work/grails/gvtodo
で、$VUE_PROJECT
は$GRAILS_PROJECT/frontend
になっています。
準備
GrailsとVueアプリケーションを起動しておいてください。それぞれ以下のコマンドで起動できます。
cd $GRAILS_PROJECT grails run-app
- Vue
cd $VUE_PROJECT yarn run dev
ページの作成
では、実際にフロントエンドの開発に入ります。
まず、$VUE_PROJECT/src/components/todo/Index.vue
を作成し、中身を以下のようにします。
<template> <div> <h1>ToDoリスト</h1> <input v-model="title"> <button @click="add">add</button> <ul> <li v-for="todo in todoList"> <input class="todo-title" v-bind:value="todo.title" @keyup.enter="edit($event, todo.id)" @blur="edit($event, todo.id)"> <button @click="remove(todo.id)">del</button> </li> </ul> </div> </template> <script> import axios from 'axios' let errorFunc = (error) => { alert(`HTTP Status: ${error.response.status}, [${error.response.data.message}]`) } const baseURL = 'http://localhost:8080/todo' export default { data: function () { return { title: '', todoList: [] } }, mounted: function () { this.list() }, methods: { list: function () { axios.get(`${baseURL}`) .then((response) => { this.todoList = response.data console.log(this.todoList) }).catch(errorFunc) }, add: function () { axios.post(`${baseURL}`, { title: this.title }) .then((response) => { this.title = '' this.list() }) .catch(errorFunc) }, edit: function (event, id) { axios.put(`${baseURL}/${id}`, { title: event.target.value }) .then((response) => { this.list() }) .catch(errorFunc) }, remove: function (id) { axios.delete(`${baseURL}/${id}`) .then((response) => { this.list() }) .catch(errorFunc) } } } </script> <style> .todo-title { border: none; } </style>
そして、上記のファイルにアクセスできるように、$VUE_PROJECT/src/router/index.js
を以下のようにします。
import Vue from 'vue' import Router from 'vue-router' import ToDoIndex from '@/components/todo/Index' Vue.use(Router) export default new Router({ routes: [ { path: '/', name: 'ToDoIndex', component: ToDoIndex } ] })
たったコレだけです!これでGrailsとVue.jsを使ったシンプルなToDoアプリケーションの完成です!
実際にhttp://localhost:8081/にアクセスして試してみてください。
なお、登録したToDoは、テキストをクリックすると編集できます。Enterを押下するか、フォーカスが外れた時点で自動的にサーバに更新リクエスト(PUT)を送ります。
今のところ全くGrailsが出てきませんでした。Grailsでいかにシンプルかつ高速にWebアプリケーションを構築できるかが分かりますね!
自分でRESTful API用のコントローラを作成する
さて、当然シンプルなAPIをいつまでも保てる保障はありません。やはりGrailsが自動で生成してくれるRESTful APIの動作を替えたい事もあるでしょう。
そこで今回は自分でRESTful API用のControllerを書いてみます。
コントローラの作成
とりあえず新しくItemコントローラを作成します。
grails> create-controller item
$GRAILS_PROJECT/grails-app/controllers/gvtodo/ItemController.groovy
が生成されるので以下のようにします。
package gvtodo import org.springframework.http.HttpStatus class ItemController { static responseFormats = ['json', 'xml'] /** * HTTPメソッド:GET、URL:/items/ */ def index() { respond(Todo.list()) } /** * HTTPメソッド:POST、URL:/items/ */ def save(Todo todo) { todo.save(flush: true) respond(todo) } /** * HTTPメソッド:PUT、URL:/items/${id} */ def update(Todo todo) { todo.save(flush: true) respond(todo) } /** * HTTPメソッド:DELETE、URL:/items/${id} */ def delete(Todo todo) { respond([message: "You are not allowed to delete ToDo!"], status: HttpStatus.FORBIDDEN) } }
これで独自のRESTful API用コントローラの完成です。
今回、Item(つまりToDo)の削除は禁止してみました。 delete(Todo todo)
アクションがそれです。
このように、resopndの第2引数にステータスコードを渡してあげることで任意のレスポンスコードをクライアントに返すことが出来ます。
UrlMappingの修正
さて、自分で追加したControllerとActionをRESTful APIっぽいURL形式にするにはどうしたらいいでしょうか?
最後にIDが有る場合は更新か削除で...でもPUTとDELETEだと動作が違うし...
考慮することが結構あって面倒臭いですよね。しかし!Grials3のUrlMappingは当然そこの当たりの面倒臭さも包み隠してくれます!
$GRAILS_PROJECT/grails-app/controllers/gvtodo/UrlMappings.groovy
を以下のように修正します。
なお、このUrlMappings.groovyを編集した後はGrailsを再起動してください。
package gvtodo class UrlMappings { static mappings = { // この1行を追加 "/manual/items"(resources: 'item') "/$controller/$action?/$id?(.$format)?"{ constraints { // apply constraints here } } "/"(view:"/index") "500"(view:'/error') "404"(view:'/notFound') } }
"/manual/items"(resources: 'item')
が意味するところは、resources
で指定したControllerに対して、RESTful API形式でアクセスできる、ということになります。
今回であればItemコントローラはRESTful APIに対応していて、それには/manual/items
でアクセスできる、ということになります。
このresourcesを利用することで、自作のControllerにRESTful API形式でアクセスできるようになります。
今回の例だと、
HTTPメソッド | URI | アクション |
---|---|---|
GET | /items | index() |
POST | /items | save() |
PUT | /items/${id} | update() |
DELETE | /items/${id} | delete() |
となります。
つまり、上記のアクション(メソッド)を持つコントローラを用意しておいて、それをUrlMappingのresourcesで指定することで、Grailsによって自動的にRESTful API形式でアクセスできるようになります。
この4つ以外にも、GSP用のcreate()
, show()
, edit()
が有りますが、今回は利用しません。
このresourcesに関してはこの公式を参照してみてください。
新しいAPIを利用するようにする
後は、Vue.jsのアクセス先を切り替えるために、$VUE_PROJECT/src/components/todo/Index.vue
内のbaseUrl
を編集してください。
// const baseURL = 'http://localhost:8080/todo' const baseURL = 'http://localhost:8080/manual/items'
これでhttp://localhost:8081/にアクセスすれば、以前と同じ用に普通にアプリケーションが使えることが確認できると思います。
また、削除もできなくなっているはずですので併せて確認してみてください。
まとめ
いかがだったでしょうか。私の長ったらしい説明は別として、Grails側がとても簡単にRESTful API対応の独自Controllerを作成できることが分かりますね。
明日はこのToDoアプリケーションに認証機構を追加します。
Grails/Vue.jsでTodoアプリ(環境構築)
これはG* Advent Calendar 2017の7日目の記事です。
この記事は、以下の5つの投稿で成り立っています。
Grails/Vue.jsでTodoアプリ(環境構築)
Grails/Vue.jsでTodoアプリ(CRUD)
Grails/Vue.jsでTodoアプリ(認証) 1/2
Grails/Vue.jsでTodoアプリ(認証) 2/2
Grails/Vue.jsでTodoアプリ(Deploy as war)
初めに
Grails 3から非常に簡単にRESTful APIを提供できるようになりました。
しかし、React.jsやAngular.jsなどはGrails 3でいい感じに扱うプラグインなどが有るようですが若干Vue.jsが蚊帳の外っぽい印象です。
そこで今回、5回に渡ってGrailsとVue.jsを連携させるサンプルアプリケーションを作ってみます。
なお、Vue.js自体は非同期通信のための機能を提供していないので、ココではaxiosを利用します。
Grailsの準備(APIサーバの構築)
とりあえず現時点で最新のGrails 3.3.1を利用しました。
プロジェクト名はgvtodo
として普通にGrailsアプリケーションを作成します。
[koji:grails]$ grails create-app gvtodo
インタラクティブモードに入ります。例のごとく初回はライブラリがダウンロードされるのでしばし待機。
[koji:grails]$ grails
完了したら今回利用する2つのドメイン(ToDoとTag)を生成します。
grails> create-domain-class todo grails> create-domain-class tag
そして、生成されたそれぞれのファイルの内容を以下のようにします。
- gvtodo/grails-app/domain/gvtodo/Todo.groovy
package gvtodo import grails.rest.Resource @Resource(uri = "/todo") class Todo { String title static hasMany = [tags: Tag] static constraints = { } }
- gvtodo/grails-app/domain/gvtodo/Tag.groovyTag.groovy
package gvtodo import grails.rest.Resource @Resource(uri = "/tags") class Tag { String title static constraints = { title(unique: true) } }
最後に、application.yml
の先頭のgrails
の部分に、CORSを有効化するべく、以下のように追記します。
grails: #以下の2行を追記 cors: enabled: true
CORSを有効にすることで、Grailsがポート8080、Vue.jsアプリケーションがポート8081で動いているような場合でも、Vue.jsからGrailsにRESTful APIでアクセスすることが出来るようになります。
これにて準備は完了です。
run-app
でGrailsを起動します。
grails> run-app | Running application... Grails application running at http://localhost:8080 in environment: development grails>
ブラウザでhttp://localhost:8080/ にアクセスすれば、Grailsの起動ページが表示されます。
しかしやはりGrailsはサクサク開発できて気持ち良いですね!
そしてなにより、Grails3から強化されたRESTful API対応のおかげで、既にRESTful APIサーバが出来上がっています! 試しに、http://localhost:8080/todo/、http://localhost:8080/tags/にアクセスしてみてください。
何やら表示されます。当然何もデータがないので空っぽです。
ではテストデータをcurlを使って適当に登録してみます。
[koji:grails]$ curl -X POST -H "Content-Type: application/json" -d '{"title": "my first todo"}' localhost:8080/todo {"id":1,"title":"my first todo","tags":null}% [koji:grails]$ curl -X POST -H "Content-Type: application/json" -d '{"title": "my second todo"}' localhost:8080/todo {"id":2,"title":"my second todo","tags":null}%
では再度http://localhost:8080/todo/にアクセスしてみてください。
なんと既にデータが登録されていますね!
特定のデータにアクセスしたいときはhttp://localhost:8080/todo/1のように末尾にIDを付与します。普通のRestful APIですね。
こんなに簡単にRESTful APIサーバが構築出来る上に、上記のデータは正真正銘データベースに登録されています。
さらにデフォルトではGrailsを再起動すればデータは消えるので、初期開発時に非常に重宝します。
Vue.jsの準備
さて、ビューに関しては全部サーバサイドでゴリゴリ!という時代ではないようですので、Vue.jsを使ってみます。
今回、SPAとしてVue.jsアプリケーションを作成していきますので、vue-cli
をインストールます。
すでにインストールしている方は以下の手順は必要ありません。
なお、自分はグローバルにvue-cli
をインストールしたくないので、適当なディレクトリにてvue-cli
をインストール、そしてその中でvueコマンドを使ってVueアプリケーションを作成する、というスタンスです。
ということで、本当にどこでも良いので適当なディレクトリで以下を実行します。
[koji:hoge]$ yarn add vue-cli
これで準備は完了です。
実際にVueアプリケーションを作るのですが、当然フロントエンドなのでGrailsとは別のディレクトリに設置してもいいのですが、今回は作成したGrailsのディレクトリ直下に作成することにします。
私の環境では/home/koji/work/grails/gvtodo
がGrailsのプロジェクトトップなので、この直下にfrontend
という名前でVueアプリケーションを作成します。
yarn run vue init webpack /home/koji/work/grails/gvtodo/frontend
すると以下のような対話シェルが起動されますので、?
で始まっている部分の質問に答える必要が有ります。
基本的には一番最初のProject name
に適当な名前を付けるだけで後はデフォルトでいいと思います。
[koji:hoge]$ yarn run vue init webpack /home/koji/work/grails/gvtodo/frontend yarn run v1.2.1 warning package.json: No license field $ /home/koji/work/grails/hoge/node_modules/.bin/vue init webpack /home/koji/work/grails/gvtodo/frontend ? Project name gvtodo ? Project description A Vue.js project ? Author Koji ? Vue build standalone ? Install vue-router? Yes ? Use ESLint to lint your code? Yes ? Pick an ESLint preset Standard ? Setup unit tests with Karma + Mocha? Yes ? Setup e2e tests with Nightwatch? Yes vue-cli · Generated "/home/koji/work/grails/gvtodo/frontend". To get started: cd /home/koji/work/grails/gvtodo/frontend npm install npm run dev Documentation can be found at https://vuejs-templates.github.io/webpack Done in 70.39s. [koji:hoge]$
これでVueアプリケーションが作成できました。
今後は、/home/koji/work/grails/gvtodo/frontend
以下にフロントエンド用のVueアプリケーションを記述していきます。
(vue-cliインストールに利用した適当なディレクトリは削除してOKです。)
なお、Vue.jsはあくまでViewのためのツールなので、サーバとの通信用に別途ライブラリが必要です。
今回はaxios
を利用することにします。
Grailsプロジェクト配下のVueアプリディレクトリに移動して、axiosをインストールします。
[koji:frontend]$ cd /home/koji/work/grails/gvtodo/frontend [koji:frontend]$ yarn add axios
これで完了!と言いたいところなのですが、Vueアプリケーションも自分自身でWebサーバをポート8080で起動するため、Grailsと被ってしまいます。
なので、このポートを別のもの(今回は8081)に変更しておきます。
frontend/config/index.js
に、port: process.env.PORT || 8080,
という記述があるので、8080
を8081
に変更するだけでOKです。
これで準備完了です!
frontendディレクトリ内で、以下のコマンドを実行してVueアプリケーションを起動してください。
[koji:frontend]$ yarn run dev yarn run v1.2.1 $ node build/dev-server.js > Starting dev server... DONE Compiled successfully in 11774ms > Listening at http://localhost:8081
これでブラウザでhttp://localhost:8081にアクセスすればVue.jsアプリケーションが動作していることが確認できます。
まとめ
とりあえずコレでGrailsをVue.js/axiosそれぞれの環境構築が出来ました。
次回から実際にGrailsをVue.sj/axiosを連携させてToDoアプリケーションを作成していきます。
標準ライブラリだけでApache GroovyでWebサーバを実装(3) - 動的な値を返す
これはG* Advent Calendar 2017の4日目の記事です。
この記事は、以下の3つの投稿で成り立っています。
標準ライブラリだけでGroovyでWebサーバを実装(1) - 基礎
標準ライブラリだけでGroovyでWebサーバを実装(2) - Groovyっぽく
標準ライブラリだけでGroovyでWebサーバを実装(3) - 動的な値を返す
初めに
今回、HTML以外のリソース(jpgとかCSSとか)も返せるようにし、さらにまるで古き良きApache/PHPスクリプトのように、お手軽にGroovyスクリプトから動的なHTMLを返せるようにしていきます。
さすがにこれぐらいの量になってくるとファイルを分けたほうがいいですが、1ファイルぽっきり&&No外部ライブラリ!というApache Groovyの素敵さを表現すべく1ファイルで引き続き書きました。
それでは今回もまずは全体のソースです。
#!/home/koji/.sdkman/candidates/groovy/current/bin/groovy import java.text.SimpleDateFormat class Server05 { Socket socket HttpRequestLine httpRequestLine Map httpRequestHeader = [:] String httpRequestBody File documentRoot static main (args) { // CliBuilderを使ってコマンドライン引数を解析 def cli = new CliBuilder(usage: "Server05 [options]") cli.h(longOpt: "help", "Show this help") cli.d(longOpt: "documentroot", args: 1, required: false, "Specify a DocumentRoot of this web server") def options = cli.parse(args) if (options.h) { cli.usage() return } ServerSocket server = new ServerSocket(8081) for (;;) { // GroovyのServerSocketは、acceptにtrueを渡してあげると自動的に別スレッドでクロージャの中身を実行してくれるように拡張されている。 server.accept(true) { Socket socket -> File documentRoot = options.d ? new File(options.d) : new File(getClass().protectionDomain.codeSource.location.path).parentFile new Server05(socket, documentRoot) } } server.close() } def Server05(Socket socket, File documentRoot) { this.socket = socket this.documentRoot = documentRoot try { // GroovyはSocketから同時にInputStreamとOutputStreamを取得できる socket.withStreams {InputStream input, OutputStream output -> readHttpRequest(input) writeHttpResponse(output) } socket.close() } catch (Exception e) { e.printStackTrace() } } def readHttpRequest(InputStream input) { BufferedReader reader = input.newReader() // 行単位の読み込みに変えたので、簡単に各Httpレスポンスの内容を解析できる String line httpRequestLine = new HttpRequestLine(reader.readLine()) // リクエストヘッダーの解析 while( !(line = reader.readLine()).isEmpty() ) { def(type, value) = line.split(": ") httpRequestHeader.put(type?.trim(), value?.trim()) } // リクエストボディの解析 if(httpRequestHeader.get('Content-Length', 0).toInteger() > 0) { char[] buf = new char[httpRequestHeader.get('Content-Length') as Integer] reader.read(buf) httpRequestBody = buf.toString() } } // https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html def writeHttpResponse(OutputStream output) { // パスから、OS上のファイルを読み込み(とうぜんセキュリティーとかもっとしっかり考慮してね!) File file = new File("${this.documentRoot.path}/${httpRequestLine.path}") HttpStatusEnum httpStatus = file.exists() ? HttpStatusEnum.OK : HttpStatusEnum.NotFound ContentTypeEnum content = ContentTypeEnum.findByExtension(httpRequestLine) def sb = new StringBuffer() sb << "HTTP/1.1 ${httpStatus}\r\n" sb << "Date: ${Server05.now()}\r\n" sb << "Server: apache-groovy-server\r\n" sb << "Connection: close\r\n" sb << "Content-type: ${content.contentType}\r\n" sb << "\r\n" output.write(sb.toString().bytes) output.write(content.doSomething(httpRequestLine, documentRoot)) output.flush() } static String now() { // Date:の仕様については以下のRFCを参照 // https://tools.ietf.org/html/rfc2616#section-14.18 // https://tools.ietf.org/html/rfc2616#section-3.3.1 SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.ENGLISH) sdf.setTimeZone(TimeZone.getTimeZone("GMT")) "${sdf.format(new Date().time)} GMT" } } /** * このHTTPサーバが扱えるファイルと拡張子の一覧。 */ enum ContentTypeEnum { HTML(["html", "htm"], "text/html"), ICO(["ico"], "image/x-icon"), CSS(["css"], "text/css"), JPG(["jpg", "jpeg"], "image/jpeg"), GROOVY(["groovy"], "text/html; charset='UTF-8'") { // .groovyファイルへのアクセスの場合、当然単純にファイルの中身を表示するだけじゃダメ! // なのでdoSomethingメソッドをoverwrite byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) { File file = new File("${documentRoot.path}/${httpRequestLine.path}") if(!file.exists()) { return "${HttpStatusEnum.NotFound}".bytes } // Groovyスクリプトを読み込んでGroovyから実行! GroovyScriptEngine gse = new GroovyScriptEngine(documentRoot.path) Binding binding = new Binding() binding.setVariable("params", [path: httpRequestLine.path, now: new Date()]) String result = gse.run(httpRequestLine.path, binding) as String result.bytes } } List<String>extensions String contentType ContentTypeEnum(List<String> extensions, String contentType) { this.extensions = extensions this.contentType = contentType } static findByExtension(String extension) { values().find { extension.toLowerCase() in it.extensions } } static findByExtension(HttpRequestLine httpRequestLine) { findByExtension(httpRequestLine.path.split("\\.").last()) ?: ContentTypeEnum.HTML } /** * default action for each requested resource(file) */ byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) { File file = new File("${documentRoot.path}/${httpRequestLine.path}") if(!file.exists()) { return "${HttpStatusEnum.NotFound}".bytes } file.bytes } } /** * このHTTPサーバが返すStatusCode。当然本来はもっとある。 */ enum HttpStatusEnum { OK(200, "OK"), Forbidden(403, "Forbidden"), NotFound(404, "Not Found") Integer code String message HttpStatusEnum (Integer code, String message){ this.code = code this.message = message } String toString() { "${this.code} ${this.message}" } } class HttpRequestLine { String vlaue String method String path String protocol HttpRequestLine(String v) { def(method, path, protocol) = v.split(" ") this.method = method this.path = path == "/" ? "index.html" : path[1 .. -1] this.protocol = protocol } }
ちょっと長いですね。
今回もSerever05.groovy
というファイル名で保存しておいて、groovyコマンドで実行すればWebサーバが起動します。
前回実装したように、起動時に-d
でドキュメントルートをしていた場合はそのパス、指定しなければこのGroovyスクリプトが有る場所にHTMLファイル等を置いておけば、http://localhost:8081/index.htmlやhttp://localhost:8081/hoge.jpgという感じにアクセスできるようになっています。
それぞれの部分の解説
メインの処理
前回から特に変わっていないので省略
HTTPリクエストの処理
前回から特に変わっていないので省略
HTTPレスポンスの処理
// https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html def writeHttpResponse(OutputStream output) { // パスから、OS上のファイルを読み込み(とうぜんセキュリティーとかもっとしっかり考慮してね!) File file = new File("${this.documentRoot.path}/${httpRequestLine.path}") HttpStatusEnum httpStatus = file.exists() ? HttpStatusEnum.OK : HttpStatusEnum.NotFound ContentTypeEnum content = ContentTypeEnum.findByExtension(httpRequestLine) def sb = new StringBuffer() sb << "HTTP/1.1 ${httpStatus}\r\n" sb << "Date: ${Server05.now()}\r\n" sb << "Server: apache-groovy-server\r\n" sb << "Connection: close\r\n" sb << "Content-type: ${content.contentType}\r\n" sb << "\r\n" output.write(sb.toString().bytes) output.write(content.doSomething(httpRequestLine, documentRoot)) output.flush() } static String now() { // Date:の仕様については以下のRFCを参照 // https://tools.ietf.org/html/rfc2616#section-14.18 // https://tools.ietf.org/html/rfc2616#section-3.3.1 SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.ENGLISH) sdf.setTimeZone(TimeZone.getTimeZone("GMT")) "${sdf.format(new Date().time)} GMT" }
実際にアクセスされたリソース(HTMLとかJPGとか)や、ファイルが存在しない場合など、状況に寄って当然HTTPステータスコードが変わってきます。
そこで、まず要求されたリソースが物理的にドキュメントルート以下に存在するのかをチェックしてあげるようにしました。
File file = new File("${this.documentRoot.path}/${httpRequestLine.path}") HttpStatusEnum httpStatus = file.exists() ? HttpStatusEnum.OK : HttpStatusEnum.NotFound ...省略... sb << "HTTP/1.1 ${httpStatus}\r\n"
ここで利用しているHttpStatusEnum
は自分で用意したものです。
特に何の変哲もないただのEnumです。
enum HttpStatusEnum { OK(200, "OK"), Forbidden(403, "Forbidden"), NotFound(404, "Not Found") Integer code String message HttpStatusEnum (Integer code, String message){ this.code = code this.message = message } String toString() { "${this.code} ${this.message}" } }
toString()
を実装することで、HTTPレスポンスヘッダー内に変数を埋め込んであげれば正しい値が出力されるようにしています。
そして、実際にリソースを読み込んでクライアントに返す部分です。
ContentTypeEnum content = ContentTypeEnum.findByExtension(httpRequestLine) ...省略 sb << "Content-type: ${content.contentType}\r\n" ...省略 output.write(content.doSomething(httpRequestLine, documentRoot))
ココでもEnumを用意して、処理の切り分け、実行まで行っています。
今までは単純にHTMLファイルを表示するだけだったのでoutput(OutputStream)にはStringを渡しておけば良かったのですが、今回JPGなどのバイナリファイルも返せるようにしたいので、OutputStream.write(byte[])
に、読み込んだリソースのバイト配列を渡してあげるようにしました。
これでテキスト(HTMLとか)であろうがバイナリ(JPGとか)であろうが統一的にクライアントにリソースを返すことが出来ます。
拡張子によって処理を切り替える
/** * このHTTPサーバが扱えるファイルと拡張子の一覧。 */ enum ContentTypeEnum { HTML(["html", "htm"], "text/html"), ICO(["ico"], "image/x-icon"), CSS(["css"], "text/css"), JPG(["jpg", "jpeg"], "image/jpeg"), GROOVY(["groovy"], "text/html; charset='UTF-8'") { // .groovyファイルへのアクセスの場合、当然単純にファイルの中身を表示するだけじゃダメ! // なのでdoSomethingメソッドをoverwrite byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) { File file = new File("${documentRoot.path}/${httpRequestLine.path}") if(!file.exists()) { return "${HttpStatusEnum.NotFound}".bytes } // Exexutable Groovy script like a old school PHP/Apache under the DocumentRoot GroovyScriptEngine gse = new GroovyScriptEngine(documentRoot.path) Binding binding = new Binding() binding.setVariable("params", [path: httpRequestLine.path, now: new Date()]) String result = gse.run(httpRequestLine.path, binding) as String result.bytes } } List<String>extensions String contentType ContentTypeEnum(List<String> extensions, String contentType) { this.extensions = extensions this.contentType = contentType } static findByExtension(String extension) { values().find { extension.toLowerCase() in it.extensions } } static findByExtension(HttpRequestLine httpRequestLine) { findByExtension(httpRequestLine.path.split("\\.").last()) ?: ContentTypeEnum.HTML } /** * default action for each requested resource(file) */ byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) { File file = new File("${documentRoot.path}/${httpRequestLine.path}") if(!file.exists()) { return "${HttpStatusEnum.NotFound}".bytes } file.bytes } }
このEnumでは、拡張子とそのContentTypeの組み合わせを保持しています。
findByExtension()
で、拡張子から該当するEnumの値を取得できます。
そしてbyte[]
を返すdoSomething()
で、基本的には単純にユーザがアクセスしたいリソースのFileインスタンスのByte配列をreturnするだけです。
ただし!当然Groovyスクリプト(.groovy)にアクセスしてきた場合はその実行結果を返したいですよね。
ということで、このEnumが用意しているデフォルトの動作doSomething
を、拡張子groovyにアクセスが来た場合に変更するようにします。
GroovyからGroovyスクリプトを実行する
GROOVY(["groovy"], "text/html; charset='UTF-8'") { // .groovyファイルへのアクセスの場合、当然単純にファイルの中身を表示するだけじゃダメ! // なのでdoSomethingメソッドをoverwrite byte[] doSomething(HttpRequestLine httpRequestLine, File documentRoot) { File file = new File("${documentRoot.path}/${httpRequestLine.path}") if(!file.exists()) { return "${HttpStatusEnum.NotFound}".bytes } // Groovyスクリプトを読み込んでGroovyから実行! GroovyScriptEngine gse = new GroovyScriptEngine(documentRoot.path) Binding binding = new Binding() binding.setVariable("params", [path: httpRequestLine.path, now: new Date()]) String result = gse.run(httpRequestLine.path, binding) as String result.bytes } }
最初の方のファイルのチェックは同じです。
それ以降のGroovyScriptEngine
の生成からがGroovyスクリプト用の独自の処理です。
Groovyは、GroovyからGroovyスクリプトを実行する方法を幾つか持っています。
その中から今回は、ファイルをGroovyスクリプトとして実行して、その結果を取得することが出来るこのGroovyScriptEngine
を使ってみました。
GroovyScriptEngine#run()
の第1引数に実際に実行したいGroovyスクリプト、省略可能な第2引数にはBindingインスタンスを渡してあげることで、実行されるGroovyスクリプト内で参照可能な変数を渡してあげることが出来ます。
今回はpath
とnow
という名前で変数を渡してあげています。
実際に実行されるGrovyスクリプトとCSS
上記のGroovyScriptEngine#run()
で実際に読み込まれるGroovyスクリプトを以下のように記述できます。
単純に実行結果をStringとして返せば良いので、Groovyの便利なGStringを埋め込み変数を使ってみました。
println "これはコンソールに出力されるよ" """ <html> <head> <link rel="stylesheet" type="text/css" href="/test.css"> <title>index.groovy</title> </head> <bod> <h1>Groovyのバージョンは。。。${GroovySystem.version}</h1> アクセスした日付は:${params.now}<br> パスは:${params.path}<br> ${(1..10).findAll{it % 2 == 0}.collect {it * 2}}<br> <img src="/test.jpg"> </body> </html> """
また、CSSもクライアントに返せるようにしたので、実際に使ってみました。
img { width: 100px; height: 100px; }
これで、groovy Server05.groovy
を起動して、Server05.groovy
がある場所と同じ場所にに上記のindex.groovy
とtest.css
を用意しておけば、http://localhost:8081/index.groovyにてその実行結果を確認できます。
まとめ
いかがだったでしょうか。
3回にわたってApache GroovyでWebサーバ自体を自分で実装してきました。
当然全くセキュリティ、パフォーマンス、正しいHTTP仕様を考慮していませんが、ざっくりこういった形でWebサーバが動いているのだな、ということが理解できたのではと思います。
標準ライブラリだけでApache GroovyでWebサーバを実装(2) - Groovyっぽく
これはG* Advent Calendar 2017の3日目の記事です。
この記事は、以下の3つの投稿で成り立っています。
標準ライブラリだけでGroovyでWebサーバを実装(1) - 基礎
標準ライブラリだけでGroovyでWebサーバを実装(2) - Groovyっぽく
標準ライブラリだけでGroovyでWebサーバを実装(3) - 動的な値を返す
初めに
今回は、標準ライブラリだけでGroovyでWebサーバを実装(1) - 基礎 のコードをGroovyっぽくして、ドキュメントルートも指定できるようしていきます。
さらに、クライアントに返すHTMLも固定ではなく、指定できるようにしたドキュメントルート配下のHTMLファイルを返すようにします。
それではまずは全体ソースを。
import java.text.SimpleDateFormat class Server03 { Socket socket HttpRequestLine httpRequestLine Map httpRequestHeader = [:] String httpRequestBody File documentRoot static main (args) { // CliBuilderを使ってコマンドライン引数を解析 def cli = new CliBuilder(usage: "Server03 [options]") cli.h(longOpt: "help", "Show this help") cli.d(longOpt: "documentroot", args: 1, required: false, "Specify a DocumentRoot of this web server") def options = cli.parse(args) if (options.h) { cli.usage() return } ServerSocket server = new ServerSocket(8081) for (;;) { // GroovyのServerSocketは、acceptにtrueを渡してあげると自動的に別スレッドでクロージャの中身を実行してくれるように拡張されている。 server.accept(true) { Socket socket -> File documentRoot = options.d ? new File(options.d) : new File(getClass().protectionDomain.codeSource.location.path).parentFile new Server03(socket, documentRoot) } } server.close() } def Server03(Socket socket, File documentRoot) { this.socket = socket this.documentRoot = documentRoot try { // GroovyはSocketから同時にInputStreamとOutputStreamを取得できる socket.withStreams {InputStream input, OutputStream output -> readHttpRequest(input) writeHttpResponse(output) } socket.close() } catch (Exception e) { e.printStackTrace() } } def readHttpRequest(InputStream input) { BufferedReader reader = input.newReader() // 行単位の読み込みに変えたので、簡単に各HTTPリクエストの内容を解析できる String line // HTTPリクエストの1行目は特殊なので別途処理 httpRequestLine = new HttpRequestLine(reader.readLine()) // リクエストヘッダーの解析 while( !(line = reader.readLine()).isEmpty() ) { def(type, value) = line.split(": ") httpRequestHeader.put(type?.trim(), value?.trim()) } // リクエストボディの解析 if(httpRequestHeader.get('Content-Length', 0).toInteger() > 0) { char[] buf = new char[httpRequestHeader.get('Content-Length') as Integer] reader.read(buf) httpRequestBody = buf.toString() } } // https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html def writeHttpResponse(OutputStream output) { // Date:の仕様については以下のRFCを参照 // https://tools.ietf.org/html/rfc2616#section-14.18 // https://tools.ietf.org/html/rfc2616#section-3.3.1 SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.ENGLISH) sdf.setTimeZone(TimeZone.getTimeZone("GMT")) String now = "${sdf.format(new Date().time)} GMT" def sb = new StringBuffer() sb << "HTTP/1.1 200 OK\r\n" sb << "Date: ${now}\r\n" sb << "Server: apache-groovy-server\r\n" sb << "Connection: close\r\n" sb << "Content-type: text/html\r\n" sb << "\r\n" sb << new File("${this.documentRoot.path}/${httpRequestLine.path}").text // GroovyJDKで追加されてるメソッド。終わる前にoutputStreamはcloseされる。 output.setBytes(sb.toString().bytes) } } class HttpRequestLine { String vlaue String method String path String protocol HttpRequestLine(String v) { def(method, path, protocol) = v.split(" ") this.method = method this.path = path == "/" ? "index.html" : path[1 .. -1] this.protocol = protocol } }
だいぶ雰囲気が変わったのではないでしょうか?
このソースをServer03.groovy
という名前などで保存して、groovyコマンドで実行すればWebサーバが起動します。
このGroovyスクリプトを配置した同じ場所に、HTMLファイルを置いて置いておけば、http://localhost:8081/index.htmlというように普通にアクセスできるようになっています。
今回はドキュメントルートを指定できるようにしたので、その指定方法や動作などは以下をご覧ください。
それぞれの部分の解説
メインの処理
class Server03 { Socket socket HttpRequestLine httpRequestLine Map httpRequestHeader = [:] String httpRequestBody File documentRoot static main (args) { // CliBuilderを使ってコマンドライン引数を解析 def cli = new CliBuilder(usage: "Server03 [options]") cli.h(longOpt: "help", "Show this help") cli.d(longOpt: "documentroot", args: 1, required: false, "Specify a DocumentRoot of this web server") def options = cli.parse(args) if (options.h) { cli.usage() return } ServerSocket server = new ServerSocket(8081) for (;;) { // GroovyのServerSocketは、acceptにtrueを渡してあげると自動的に別スレッドでクロージャの中身を実行してくれるように拡張されている。 server.accept(true) { Socket socket -> File documentRoot = options.d ? new File(options.d) : new File(getClass().protectionDomain.codeSource.location.path).parentFile new Server03(socket, documentRoot) } } server.close() } def Server03(Socket socket, File documentRoot) { this.socket = socket this.documentRoot = documentRoot try { // GroovyはSocketから同時にInputStreamとOutputStreamを取得できる socket.withStreams {InputStream input, OutputStream output -> readHttpRequest(input) writeHttpResponse(output) } socket.close() } catch (Exception e) { e.printStackTrace() } } ...省略... }
かなり前回とは異なっています。
まず、classがRunnableインタフェースを実装していません。
これは、Apache GroovyがJava標準のServerSocket
を拡張してくれていて、第1引数にtrueを渡すことで、第2引数を渡すクロージャを別のスレッドで自動的に実行してくれる為、態々自分でRunnableを実装する必要が無くなったためです。
さらに、コンストラクタ内でSocket#withStreams()
を利用することで、渡すクロージャにInputStreamとOutputStreamを同時に渡してくれる親切設計になっています。
また、今回はドキュメントルートを指定できるようにしたので、この部分を拡張しています。
具体的にはまずCliBuilder
を利用して、-d
、もしくは--documentroot
オプションにドキュメントルートを指定できるようにしました。
以下の部分です。
def cli = new CliBuilder(usage: "Server03 [options]") cli.h(longOpt: "help", "Show this help") cli.d(longOpt: "documentroot", args: 1, required: false, "Specify a DocumentRoot of this web server")
もしドキュメントルートが指定されなかった場合、このGroovyスクリプトが配置されているディレクトリをドキュメントルートにするようにしました。
File documentRoot = options.d ? new File(options.d) : new File(getClass().protectionDomain.codeSource.location.path).parentFile
ちょっと長ったらしいですが、このようにして自分自身(Groovyを実行したディレクトリではなくて、Groovyスクリプトが居る場所)のパスを取得することが出来ます。
本当はmainメソッドの中で実行してしまっても良いようなものですが、そうするとcodeSourceがnullを返してくるので、クロージャの中で定義しました。(他に良い方法が有るはず)
もしgroovy Server03.groovy -d /tmp
と実行すると、/tmp
がドキュメントルートになるので、例えば/tmp/hoge.html
を作成しておけば、http://localhost:8081/hoge.htmlでアクセスできるようになります。
HTTPリクエストの処理
class Server03 { ...省略... def readHttpRequest(InputStream input) { BufferedReader reader = input.newReader() // 行単位の読み込みに変えたので、簡単に各HTTPリクエストの内容を解析できる String line // HTTPリクエストの1行目は特殊なので別途処理 httpRequestLine = new HttpRequestLine(reader.readLine()) // リクエストヘッダーの解析 while( !(line = reader.readLine()).isEmpty() ) { def(type, value) = line.split(": ") httpRequestHeader.put(type?.trim(), value?.trim()) } // リクエストボディの解析 if(httpRequestHeader.get('Content-Length', 0).toInteger() > 0) { char[] buf = new char[httpRequestHeader.get('Content-Length') as Integer] reader.read(buf) httpRequestBody = buf.toString() } } ...省略... } class HttpRequestLine { String vlaue String method String path String protocol HttpRequestLine(String v) { def(method, path, protocol) = v.split(" ") this.method = method this.path = path == "/" ? "index.html" : path[1 .. -1] this.protocol = protocol } }
前回のものと比べると大分コード量が少なくなりました。
GroovyがInputStream#newSteram()
を用意してくれていて、これがBufferedReaderインスタンスを返してくれるのでこれを利用しない手はありません。
さらに、BufferedReader#readLine()
でHTTPリクエストヘッダーを簡単に1行ずつ読み込めるようになったので、もし読み込んだ行が空っぽ(改行一つだけ)の場合、それをHTTPリクエストヘッダーの終わりと判断できるので自作のQueueも必要無くなりました。
HTTPリクエストボディに関しては、Content-Length
で指定されたバイト数分読み込みしてあげたいので、そのサイズ分のchar[]
配列を生成して、BufferedReader.read(buf[])
に渡して一気に読み込んでいます。
さて、基本的にHTTPリクエストヘッダーは、各行がCRLF
で区切られて、それぞれの行は項目名:値
のような形式になっています。
しかし、1行目だけは特別で、そのルールに該当しません。(POST /index.html HTTP/1.1
のような形式になっています。)
また、その最初の行の値を利用してアクセスしたいリソースを特定する必要も有りますので、この部分は専用のHttpRequestLine
クラスを用意してそこで管理するようにしました。
HTTPレスポンスの処理
// https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html def writeHttpResponse(OutputStream output) { // Date:の仕様については以下のRFCを参照 // https://tools.ietf.org/html/rfc2616#section-14.18 // https://tools.ietf.org/html/rfc2616#section-3.3.1 SimpleDateFormat sdf = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss", Locale.ENGLISH) sdf.setTimeZone(TimeZone.getTimeZone("GMT")) String now = "${sdf.format(new Date().time)} GMT" def sb = new StringBuffer() sb << "HTTP/1.1 200 OK\r\n" sb << "Date: ${now}\r\n" sb << "Server: apache-groovy-server\r\n" sb << "Connection: close\r\n" sb << "Content-type: text/html\r\n" sb << "\r\n" sb << new File("${this.documentRoot.path}/${httpRequestLine.path}").text // GroovyJDKで追加されてるメソッド。終わる前にoutputStreamはcloseされる。 output.setBytes(sb.toString().bytes) }
こちらに関しては大きくは変わっていませんが、
sb << new File("${this.documentRoot.path}/${httpRequestLine.path}").text
この部分で、ユーザがURLに指定したリソース(現状HTML限定)のテキストを返すようにしています。
これよって、例えばgroovy Server03.groovy
と実行しているのであれば、Server03.groovyと同じディレクトリにあるHTMLファイルにhttp://localhost:8081/index.html
としてアクセスできるようになりました。
また、OutputStream#setBytes()
というGroovyのメソッドを利用すれば、自分でclose()を書く必要はありません。
まとめ
いかがだったでしょうか。GroovyがJDKを拡張してくれているおかげで、大分スッキリコードが書けるようになりました。
while文を使っていたり、まだまだJavaの匂いは残っていますが、同じファイル内に複数のclassを定義できるなど、やはりGroovyでコードを書くことで大分楽が出来るな〜と改めて思いました。
標準ライブラリだけでApache GroovyでWebサーバを実装(1) - 基礎
これはG* Advent Calendar 2017の2日目の記事です。
この記事は、以下の3つの投稿で成り立っています。
標準ライブラリだけでGroovyでWebサーバを実装(1) - 基礎
標準ライブラリだけでGroovyでWebサーバを実装(2) - Groovyっぽく
標準ライブラリだけでGroovyでWebサーバを実装(3) - 動的な値を返す
初めに
Go言語やRust等、システムよりのプログラミングに利用しやすい言語の勢いが出てきている昨今、Webサーバ自体を実装するというサンプルをよく目にするようになりました。
今回、Apache Groovyで自分でもWebサーバを実装してみました。
あくまでWebサーバの動作をざっくり理解するためのものなのでセキュリティーなどは一切考慮していません。
先ずは全体ソースをご覧ください。 なお、今回は最も基本的な部分を理解するための初回なので、ほぼほぼJavaのコードになります。
import java.text.SimpleDateFormat class Server02 implements Runnable { Socket socket static main (args) { ServerSocket server = new ServerSocket(8081) for (;;) { // クライアントからの接続を待つ Socket socket = server.accept() // クライアントから接続されたら、別スレッドでその要求を処理する。 Server02 server02 = new Server02(socket) Thread thread = new Thread(server02) thread.start() } server.close() } Server02(Socket socket) { this.socket = socket } void run () { try { InputStream input = socket.getInputStream() readHttpRequest(input) OutputStream output = socket.getOutputStream() writeHttpResponse(output) socket.close() } catch (Exception e) { e.printStackTrace() } } def createQueue (Integer size) { List q = [] return { value -> if (q.size() >= size) { q = q.tail() + value } else { q << value } q } } // 全てはintでやりとりされる // https://docs.oracle.com/javase/jp/8/docs/api/java/io/InputStream.html def readHttpRequest(InputStream input) { Integer ch Integer contentLength = 0 List<Integer> requestHeader = [] // KeepAlive(HTTP1.1)の場合、そもそもストリームの終わりがないので、2回連続CRLFが現れたらヘッダーの終わりと判断する。 def queue = createQueue(4) for(;;) { // InputStream#read() returns integer. but this value is between 0 and 255. also it is 1 Byte. ch = input.read() if (ch == -1 || queue(ch) == [13, 10, 13, 10]) { break } else { requestHeader.add(ch) } } // Content-typeが指定されているのであれば、その分Bodyが有るはずなので読み込み List headerLines = new String(requestHeader as byte[]).split("\r\n") String contentLengthString = headerLines.find {it.startsWith("Content-Length")} if (contentLengthString) { contentLength = contentLengthString.split(":")[1].trim() as Integer } // InputStream#read()は1バイトずつ読み込んでくれるので、Content-Length回readを実行すればOK // and already ended header. so you can read from continued. List<Integer> requestBody = [] if (contentLength > 0) { (0 ..< contentLength).each { requestBody.add(input.read()) } } println new String(requestHeader as byte[]) println "-" * 30 println new String(requestBody as byte[]) } // https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html def writeHttpResponse(OutputStream output) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss") sdf.setTimeZone(TimeZone.getTimeZone("GMT")) String now = "${sdf.format(new Date())} GMT" def sb = new StringBuffer() sb << "HTTP/1.1 200 OK\r\n" sb << "Date: ${now}\r\n" sb << "Server: apache-groovy-server\r\n" sb << "Connection: close\r\n" sb << "Content-type: text/html\r\n" sb << "\r\n" sb << "<html><head><title></title><body>hello world<br>${now}</body></html>" sb.toString().each { output.write((int)it) } output.flush() } }
これをServer02.groovy
というようなファイル名で保存してgroovyコマンドで実行すればもうオリジナルのWebサーバの完成です!
それぞれの部分解説
メインの処理
class Server02 implements Runnable { Socket socket static main (args) { ServerSocket server = new ServerSocket(8081) for (;;) { // クライアントからの接続を待つ Socket socket = server.accept() // クライアントから接続されたら、別スレッドでその要求を処理する。 Server02 server02 = new Server02(socket) Thread thread = new Thread(server02) thread.start() } server.close() } Server02(Socket socket) { this.socket = socket } void run () { try { InputStream input = socket.getInputStream() readHttpRequest(input) OutputStream output = socket.getOutputStream() writeHttpResponse(output) socket.close() } catch (Exception e) { e.printStackTrace() } } ...省略... }
上記のコードが、実際にGroovyでサーバを8081番ポートで起動して、ユーザからのリクエストを待つ処理になります。
クライアントからのリクエストの度に、それぞれ別スレッドでServer02#run()
が実行されます。
ServerSocket
インスタンスで、サーバを起動してはいますが、この時点では別にWebサーバでもないし、FTPサーバでもDBサーバでもありません。
単純に指定したポートでクライアントからの通信を待ち受けるプログラムになります。
このプログラムが、クライアントから送られてくるバイト列を解析して、処理して、クライアントにその結果を返す、という一連の流れをHTTPというルールに則って行うことで初めてWebサーバとなります。
では、続いてこのプログラムをHTTPサーバにするための2大項目をそれぞれ見ていきます。
HTTPリクエストの処理
ユーザから送られてくるバイト列を、HTTPのルールに則って解析します。
def createQueue (Integer size) { List q = [] return { value -> if (q.size() >= size) { q = q.tail() + value } else { q << value } q } } def readHttpRequest(InputStream input) { Integer ch Integer contentLength = 0 List<Integer> requestHeader = [] // KeepAlive(HTTP1.1)の場合、そもそもストリームの終わりがないので、2回連続CRLFが現れたらヘッダーの終わりと判断する。 def queue = createQueue(4) for(;;) { // InputStream#read() returns integer. but this value is between 0 and 255. also it is 1 Byte. ch = input.read() if (ch == -1 || queue(ch) == [13, 10, 13, 10]) { break } else { requestHeader.add(ch) } } // Content-typeが指定されているのであれば、その分Bodyが有るはずなので読み込み List headerLines = new String(requestHeader as byte[]).split("\r\n") String contentLengthString = headerLines.find {it.startsWith("Content-Length")} if (contentLengthString) { contentLength = contentLengthString.split(":")[1].trim() as Integer } // InputStream#read()は1バイトずつ読み込んでくれるので、Content-Length回readを実行すればOK // and already ended header. so you can read from continued. List<Integer> requestBody = [] if (contentLength > 0) { (0 ..< contentLength).each { requestBody.add(input.read()) } } println new String(requestHeader as byte[]) println "-" * 30 println new String(requestBody as byte[]) }
HTTPリクエスト自体はシンプルなもので、以下のような物がクライアントから送られてきます。
curl http://localhost:8081 --data "{a:b}" --data "message:どうですか"
POST / HTTP/1.1 User-Agent: curl/7.35.0 Host: localhost:8081 Accept: */* Content-Length: 29 Content-Type: application/x-www-form-urlencoded ------------------------------ {a:b}&message:どうですか
これは実際にこのプログラムを起動して、curlコマンドでアクセスした時に表示される内容です。
今回は最も低レベルなInputStream#read()
を使って、クライアントから送られてくるリクエスト(HTTPリクエスト)を1バイトずつ読み込んで処理しています。
HTTPリクエストヘッダー
注意する点としては、ブラウザなどのクライアントが、keepAliveをオンとしてアクセスしてくると、InputStream#read()
は終わらない(-1を返さない)ので、無限ループになってしまう点です。
HTTPの仕様として各ヘッダー行はCRLF
で終わり、HTTPリクエストヘッダーは空行(CRLFのみ)で終わる、と決められているので、InputStream#read()
で読み込んだ直近の4Byte(本当はIntegerだけど0-255の値なので実質Byte)を自作のQueueに貯めこんでいます。
もし直近の4ByteがCRLFCRLF
の場合、それはHTTPリクエストヘッダーの終わりとういことなので、ヘッダーの読み込みを終了するようにしています。
HTTPリクエストボディ
もしHTTPリクエストヘッダー内にContent−Length
が指定されている場合、HTTPリクエストボディが存在します。
InputStreamインスタンスはread()でどこまで読み込まれたかを保持していますので、引き続きInputStream#read()
をCotnent-Length
分実行してあげることで、HTTPリクエストボディ部分が取得できます。
しかし1点注意しなければならないことが有ります。マルチバイト文字です。
ASCIIであれば、1Byteずつデータを取り出して、それを1文字として扱っても問題ありませんが、日本語を始めマルチバイト文字が送られてくる可能性のあるHTTリクエストボディはそれでは不味いです。
そのため、InputStream#read()
で取得したByteデータはそのまま即1文字として処理せずに、Integerのリスト等に貯めこんでおいて、あとで一気にString型に変換して上げる必要が有ります。
GroovyでもJavaのプリミティブ型の配列(byte[]
)にas
で簡単に変換することが出来ます。
(ちなみに私のこの環境はLinuxですので、文字コードはUTF-8としてnew String()されています。)
HTTPレスポンスの処理
さて、クライアントからのリクエストを解析したら当然最後にはクライアントに何かデータを返して上げる必要が有ります。
Webサーバなので、当然HTMLですね!
// https://docs.oracle.com/javase/jp/8/docs/api/java/io/OutputStream.html def writeHttpResponse(OutputStream output) { SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss") sdf.setTimeZone(TimeZone.getTimeZone("GMT")) String now = "${sdf.format(new Date())} GMT" def sb = new StringBuffer() sb << "HTTP/1.1 200 OK\r\n" sb << "Date: ${now}\r\n" sb << "Server: apache-groovy-server\r\n" sb << "Connection: close\r\n" sb << "Content-type: text/html\r\n" sb << "\r\n" sb << "<html><head><title></title><body>hello world<br>${now}</body></html>" sb.toString().each { output.write((int)it) } output.flush() }
やっていることは言ったってシンプルで、クライアントに返したい情報をHTTPレスポンスのルールに則ってStringで記述しておいて、それを1文字ずつIntegerに変換してOutputStream#write(int)
に渡してあげているだけです。
なお、これはあくまで最低限ブラウザがHTTPレスポンスを一応HTMLとして解釈して表示してくれる最低限のデータを指定しているだけなので、実際のHTTP1.1のルール通りにはなっていません。
まとめ
いかがだったでしょうか。ただHTTPサーバを立てるだけなのに結構大変ですね。
さらに本来は正しいHTTPの仕様に準拠する必要も有りますしセキュリティーの問題も有ります。
ただ、やはりこのように自分でソケットサーバ/HTTPサーバを実装してみることで色々理解が進みますね。
Apache GroovyとGrailsでApache Solrを利用する(組み込み)
これはG* Advent Calendar 2017の1日目の記事です。
さて、検索エンジンといえばElasticSearchの勢いが凄いですが、やはりApache Groovyで利用するなら同じApacheファミリーのApache Solrですよね!
ただ、RESTful APIを利用してSolrとやりとりするともうソレでネタが終了、という事情もありつつ、態々Apache Solr自体をサーバとして起動したくない、出来ない状態も当然有ると思いますので、今回Apache Solrを組み込みモードでApache Groovy、そしてGrailsから利用する方法をまとめてみました。
Apache Solrの準備
インストール
この記事を書いている時点で最新は7.1なのですが、どうも7.0から検索のための条件指定などの仕様がだいぶ変わっているようで、まだキャッチアップできていないので今回はApache Solr 6.6を利用します。
インストールは簡単で、単純にzipを落としてきて解凍するだけです。
[koji:advent]$ pwd /home/koji/work/advent [koji:advent]$ wget http://archive.apache.org/dist/lucene/solr/6.6.2/solr-6.6.2.zip [koji:advent]$ unzip solr-6.6.2.zip [koji:advent]$ cd solr-6.6.2 [koji:solr-7.1.0]$ pwd /home/koji/work/advent/solr-6.6.2
起動
では実際に起動してみましょう。
bin/solr
を使って、Solrの制御を行います。
起動するにはsolr start
を実行します。
[koji:solr-6.6.2]$ bin/solr start Waiting up to 180 seconds to see Solr running on port 8983 [\] Started Solr server on port 8983 (pid=29077). Happy searching! [koji:solr-6.6.2]$
これでApache Solrが起動しました。
http://localhost:8983にブラウザでアクセスすると、Apache Solrの管理画面が確認できます。
停止
同様に、solr
コマンドを使います。
solr stop -all
を実行します。
[koji:solr-6.6.2]$ bin/solr stop -all Sending stop command to Solr running on port 8983 ... waiting up to 180 seconds to allow Jetty process 29077 to stop gracefully. [koji:solr-6.6.2]$
SOLR_HOMEの作成
デフォルトだと、Solrのインストールディレクトリ/server/solr
が、SOLR_HOMEとなります。
今回の私の環境であれば/home/koji/work/advent/solr-6.6.2/server/solr
です。
SOLR_HOMEには、Apache Solr全体の設定(起動ポートとか)を管理するsolr.xml
を設置して、実際のSolrコア(インデックス)はこのSOLR_HOME配下に作成されることになります。
折角なので自分でSOLR_HOMEを作ってみます。
とりあえず、Apache Solrのインストールディレクトリに、my_home
ディレクトリを作って、そこにデフォルトのsolr.xml
をコピーして起動します。
# ディレクトリを作ってデフォルトのsolr.xmlをコピー [koji:solr-6.6.2]$ pwd /home/koji/work/advent/solr-6.6.2 [koji:solr-6.6.2]$ mkdir my_home [koji:solr-6.6.2]$ cp server/solr/solr.xml my_home/. [koji:solr-6.6.2]$ # -sオプションを使って、自分で作ったSOLR_HOMEでApache Solrを起動 [koji:solr-6.6.2]$ bin/solr start -s my_home Waiting up to 180 seconds to see Solr running on port 8983 [\] Started Solr server on port 8983 (pid=30227). Happy searching! [koji:solr-6.6.2]$
これでオリジナルのSOLR_HOMEが作成できました。
コアの作成
次に、実際に検索対象となるデータを保存するコアを作成します。
DBMSで言うところのデータベースに当たります。
管理コマンドsolr
を使って作成します。
具体的にはsolr create -c コア名
になります。
作成されたコアは、起動時に指定したSOLR_HOME配下に作成されます。
では、todo
という名前でコアを作成してみます。
[koji:solr-6.6.2]$ pwd /home/koji/work/advent/solr-6.6.2 [koji:solr-6.6.2]$ bin/solr create -c todo Copying configuration to new core instance directory: /home/koji/work/advent/solr-6.6.2/my_home/todo Creating new core 'todo' using command: http://localhost:8983/solr/admin/cores?action=CREATE&name=todo&instanceDir=todo { "responseHeader":{ "status":0, "QTime":1367}, "core":"todo"} [koji:solr-6.6.2]$ cd my_home [koji:my_home]$ ls solr.xml todo #<---- todo ディレクトリが作成されている [koji:my_home]$
これでコアの作成は完了です。
フィールドの作成
さて、SOLR_HOMEとコア作成が完了しました。次に実際に検索データのフィールドを定義していきます。
RDBMSで言うところのテーブルとカラムみたいな感じです。
管理画面からGUIで設定することも可能ですが、ココは標準のRESTful API経由で操作してみます。
curl -X POST -H 'Content-type: application/json' -d ' {"add-field": { "name": "title", "type": "text_ja", "indexed": "true", "stored": "true" }}' http://localhost:8983/solr/todo/schema curl -X POST -H 'Content-type: application/json' -d ' {"add-field": { "name": "tag", "type": "string", "indexed": "true", "stored": "true", "multiValued": "true" }}' http://localhost:8983/solr/todo/schema
title
とtag
というフィールドを追加しました。
title
がToDoのタイトルで、そのToDoは複数のtag
を持てる感じです。
このフィールドタイプの設定などは非常に複雑なので公式ドキュメントなどを参照してください。
なお、自動的にid
というフィールドがApache Solrによって作成されています。
インデックスの登録
同様に専用のAPI経由でテストデータを登録してみます。
curl -X POST -H 'Content-Type: application/json' -d '[{"id": "1", "title":"アドベントカレンダー2018登録", "tag":["タグ1", "タグ2"]}]' http://localhost:8983/solr/todo/update?commit=true curl -X POST -H 'Content-Type: application/json' -d '[{"id": "2", "title":"卵を買って帰る", "tag":["家事"]}]' http://localhost:8983/solr/todo/update?commit=true curl -X POST -H 'Content-Type: application/json' -d '[{"id": "3", "title":"抱負を決める", "tag":["2018"]}]' http://localhost:8983/solr/todo/update?commit=true
コレにてApache Solrの準備は完了です。
Groovyから組み込みSolrを利用する
@Grab('org.apache.solr:solr-core:6.6.2') @Grab('org.slf4j:slf4j-simple:1.7.12') import org.apache.solr.core.CoreContainer import org.apache.solr.client.solrj.SolrQuery import org.apache.solr.client.solrj.embedded.EmbeddedSolrServer import org.apache.solr.client.solrj.response.QueryResponse Closure displayResult = {QueryResponse qr -> qr.results.each { println it } } String solrHome = "/home/koji/work/advent/solr-6.6.2/my_home" def cores = new CoreContainer(solrHome) cores.load() String coreName = "todo" def server = new EmbeddedSolrServer(cores, coreName) // 全件取得のクエリ // http://localhost:8983/solr/todo/select?defType=dismax&q=2018&qf=tag+title&wt=json def q = new SolrQuery("抱負") // もし細かくパラメータを自分で指定したいなら以下のようにsetメソッドで指定できる //q.set("q", "抱負") //q.set("defType", "dismax") // qfを使うために。 //q.set("qf", "id title tag") // 検索対象のフィールド。7系以降は手動で指定しないとダメっぽい? displayResult(server.query(q)) // 検索条件を変える q = new SolrQuery("2018") displayResult(server.query(q)) // 形態素解析も大丈夫 q = new SolrQuery("帰る") displayResult(server.query(q)) // ちゃんとCloseしましょう server.close()
上記のコードを実行すれば、Apache Solrが組み込みモードで起動してGroovyから利用できます。
特に難しい部分はありません。
Grabで実際に利用するSolrを指定してあげます。
@Grab('org.apache.solr:solr-core:6.6.2')
そして、作成したSOLR_HOMEとコアを指定してあげます。
String solrHome = "/home/koji/work/advent/solr-6.6.2/my_home" def cores = new CoreContainer(solrHome) cores.load() String coreName = "todo" def server = new EmbeddedSolrServer(cores, coreName)
後は基本的にSolrQueryのインスタンスで検索条件等を作成して、それをEmbeddedSolrServerインスタンスのquery()メソッドに渡してあげるだけです。
なお、Apache Solr7.0以降、どうも検索条件に指定する方法が変わったらしく、コメントに示しているようにワザワザ検索対象フィールド名を指定しないといけなくなったようです。
Grailsから組み込みSolrを利用する
ではGrailsからApache Solrを組み込みモードで利用してみます。
やることは当然Groovyの方と同じなのですが、Grails自体がサーバとして動いているので、当然Grailsの起動/終了時に併せてApache Solrの起動と停止もして上げる必要が有ります。
今回、Grails 3.3.1で検証しました。
[koji:advent]$ grails create-app advent2017
依存関係の指定
build.gradle
のdependencies
に、Solr-CoreとSolrjを追加します。
dependencies { ... compile 'org.apache.solr:solr-core:6.6.2' compile 'org.apache.solr:solr-solrj:6.6.2' }
ココがGroovyスクリプトの方と少し違うところで、Grails 3.3.1の場合はsolr-solrj
の方も指定して上げる必要が有ります。
これは、どうもGrailsが乗っているSpringBootが使っているstarter-data**みたいなライブラリが古いSolrjをデフォルトで指定しているためのようです。
Apache Solrを扱うクラス
実際にSolrを組み込みモードで起動して、検索などの操作を行うクラスを用意します。
src/advent2017/solr
配下にSolrServer.groovy
として、以下のコードを用意します。
内容自体はGroovyスクリプトの方と基本的に同じです。
package advent2017.solr import org.apache.solr.client.solrj.SolrQuery import org.apache.solr.client.solrj.embedded.EmbeddedSolrServer import org.apache.solr.client.solrj.response.QueryResponse import org.apache.solr.common.SolrInputDocument import org.apache.solr.core.CoreContainer class SolrServer { EmbeddedSolrServer embeddedSolrServer String solrHome String solrCoreName SolrServer(String solrHome, String solrCoreName) { this.solrHome = solrHome this.solrCoreName = solrCoreName } def start() { def cores = new CoreContainer(solrHome) cores.load() embeddedSolrServer = new EmbeddedSolrServer(cores, solrCoreName) } def stop() { embeddedSolrServer.close() } def commit() { embeddedSolrServer.commit() } QueryResponse query(SolrQuery solrQuery) { embeddedSolrServer.query(solrQuery) } QueryResponse search(String query, Integer rows = 10) { def solrQuery = new SolrQuery(query) solrQuery.setRows(rows) embeddedSolrServer.query(solrQuery) } def save(SolrInputDocument solrInputDocument) { embeddedSolrServer.add(solrInputDocument) commit() } def save(List<SolrInputDocument> solrInputDocumentList) { solrInputDocumentList.each { embeddedSolrServer.add(it) } commit() } }
Apache Solrを扱うクラスの管理
作成したSolrServer
をGrails/SpringにDIしてもらえるようにします。
Grails3なので、@Component
等を指定してもいいのですが個人的にresoruces.groovy
の方が好きなので、reseources.groovy
に以下を追記します。
import advent2017.solr.SolrServer beans = { solrServer(SolrServer, "/home/koji/work/advent/solr-6.6.2/my_home", "todo") }
組み込みApache Solrの起動と終了
さて、Grailsなので当然Grails起動中はずっとApache Solrに組み込みモードで起動していてもらう必要が有ります。
そしてGrails終了時にはキチンとApache Solrにも終了してもらはなければなりません。
ということでBootstrap.groovy
を以下のようにします。
package advent2017 class BootStrap { def solrServer def init = { servletContext -> solrServer.start() } def destroy = { solrServer.stop() } }
確認用コードの追加
最後に、実際に検索するControllerとviewを作成します。
grails> create-controller index
controller:
package advent2017 import org.apache.solr.client.solrj.response.QueryResponse import org.apache.solr.common.SolrDocument class IndexController { def solrServer def index() { forward action: "search" } def search() { String searchQuery = params.query if (!searchQuery) { render view: "index", model: [searchQuery: "", records: []] return } QueryResponse queryResponse = solrServer.search(searchQuery) List<SolrDocument> records = queryResponse.results.collect { it } render view: "index", model: [searchQuery: searchQuery, count: queryResponse.results.numFound, records: records] } }
view:
<!doctype html> <html> <head> <meta name="layout" content="main"/> <title>G* アドカレ2017</title> <asset:link rel="icon" href="favicon.ico" type="image/x-ico" /> </head> <body> <div id="content" role="main"> <section class="row colset-2-its"> <h1>G* アドカレ2017</h1> <g:form controller="index" action="search" class="centered"> <g:textField name="query" value="${searchQuery}" /> <g:submitButton name="検索" /> </g:form> <p>以下に検索結果をそのまま出すよ</p> <ul> <g:each in="${records}" var="record"> <li>${record}</li> </g:each> </ul> </section> </div> </body> </html>
コレで準備は完了です!
実際にGrailsを起動してhttp://localhost:8080/indexにアクセスすれば、Grailsが組み込みモードでSolrを利用できていることが確認できます。
まとめ
駆け足になりましたが、Apache GroovyとGrailsで組み込みモードでApache Solrを利用する方法をご紹介しました。
インデックスを登録、更新、削除したりするコードも載せたかったのですが時間切れということで。。。時間を見つけてこの記事に追記したいと思います。