saba1024のブログ

どうでも良い思いついた事とかプログラミング関係のメモとか書いていきます。

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.gradledependenciesに以下を追記します。 なお、下記のプラグインは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']
]

変更点の概要は大まかに以下のとおりです

  1. ログアウトがデフォルトだとPOST限定なので、GETなどでも行えるようにする
  2. 権限の確認方法をアノテーションベースからURLベースに変更
  3. SpringSecurityRESTの設定を追加
  4. SpringSecurityCoreがどのURLに対してどのロールを要求するのか設定

grails.plugin.springsecurity.filterChain.chainMap

これはSpringSecurityRESTの為に追加しました。
コード内にJOINED_FILTERSという項目が現れていますが、コレはどういった認証方法(Filter)をpattern(URI)に対して許可/利用するか、というものになります。

すでにSpringSecurityCoreによってJOINED_FILTERSには複数の認証方法が定義されていますが、SpringSecurityRESTによってさらにrestTokenValidationFilterrestExceptionTranslationFilterが追加されています。

つまり、以下の2行では

[pattern: '/api/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'],
[pattern: '/manual/**',filters: 'JOINED_FILTERS,-anonymousAuthenticationFilter,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'],

/api/**/manual/**へのアクセスの場合、 anonymousAuthenticationFilterexceptionTranslationFilterauthenticationProcessingFiltersecurityContextPersistenceFilterrememberMeAuthenticationFilter、 を認証方法(Filter)から省いています。
そして、すでに述べた通り、restTokenValidationFilterrestExceptionTranslationFilterの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_USERROLE_ADMINのロールを持っているユーザのみアクセス可、としています。

ココで1点分かりづらい部分が有るのですが、/api/**には、SpringSecurityRESTによって、

  1. login
  2. logout
  3. validate

の3つのURLがGrailsに追加されています。
しかし、/api/**にアクセスするのにROLE_USERROLE_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)">

        &nbsp;<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経由で認証を行うようにしましょう!

参考

Spring Security Core
Spring Security REST

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)">

        &nbsp;<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-appGrailsを起動します。

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/gvtodoGrailsのプロジェクトトップなので、この直下に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,という記述があるので、80808081に変更するだけで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.htmlhttp://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スクリプト内で参照可能な変数を渡してあげることが出来ます。
今回はpathnowという名前で変数を渡してあげています。

実際に実行される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.groovytest.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

titletagというフィールドを追加しました。
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.gradledependenciesに、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を扱うクラスの管理

作成したSolrServerGrails/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を利用する方法をご紹介しました。
インデックスを登録、更新、削除したりするコードも載せたかったのですが時間切れということで。。。時間を見つけてこの記事に追記したいと思います。