Apache Groovyで画像処理の基礎
本記事はG* Advent Calendar 2017の21日目の記事です。
ピクセル
Apache Groovyでも、Javaが用意しているBufferedImage
とImageIO
を利用することで、ピクセル単位で画像を取り扱うことが出来ます。
各ピクセルの正体は単なるInteger(32bit)です。
そしてその32bitの数値を8bitずつの合計4つに区切って、先頭ビットからそれぞれAlpha
、Red
、Green
、Blue
の値として扱います。
つまり,
11111111 00000000 10101010 11001100
というIntegerは、
11111111
(Alpha)
00000000
(Red)
10101010
(Green)
11001100
(Blue)
となります。
ただし、単純にInteger(10進数)の計算では上記の値をそれぞれ取得するのは難しいので、ビット演算(右シフト)で求めてあげます。
ついでに、読み込みと書き込みに必要なリソースを提供してくれるメソッドと保存に利用するメソッドを追加して、以下のようなクラスを用意しました。
import java.awt.image.BufferedImage import javax.imageio.ImageIO class ImageUtil { static Integer alpha(Integer v) {v >>> 24} static Integer red(Integer v) {v >> 16&0xff} static Integer green(Integer v) {v >> 8&0xff} static Integer blue(Integer v) {v&0xff} static Integer rgb(Integer red, Integer green, Integer blue) { 0xff000000 | red << 16 | green << 8 | blue } static Integer rgb(Integer alpha, Integer red, Integer green, Integer blue) { alpha << 24| red << 16 | green << 8 | blue } static withStreams(File rawFile, Closure clj) { BufferedImage bufReader = ImageIO.read(rawFile) BufferedImage bufWriter = new BufferedImage(bufReader.width, bufReader.height, BufferedImage.TYPE_INT_RGB) clj(bufReader, bufWriter) } static save(BufferedImage buf, String fileName) { File file = new File("${fileName}") ImageIO.write(buf, "jpg", file) } }
上記のコードを利用しつつ、以下の画像を加工してみます。
ネガポジ変換
String dir = "/home/koji/work/groovy/image-processing" String fileName = "test.jpg" File file = new File("${dir}/${fileName}") // ネガポジ変換 ImageUtil.withStreams(file) { BufferedImage bufReader, BufferedImage bufWriter -> Integer width = bufReader.width Integer height = bufReader.height // for each line(y) (0..<height).each {Integer y -> // for each columns(x) (0..<width).each {Integer x -> Integer colorInfo = bufReader.getRGB(x, y) Integer red = 255 - ImageUtil.red(colorInfo) Integer green = 255 - ImageUtil.green(colorInfo) Integer blue = 255 - ImageUtil.blue(colorInfo) Integer rgb = ImageUtil.rgb(red, green, blue) bufWriter.setRGB(x, y, rgb) } } ImageUtil.save(bufWriter, "${dir}/negaposi.jpg") }
左右反転
String dir = "/home/koji/work/groovy/image-processing" String fileName = "test.jpg" File file = new File("${dir}/${fileName}") // 左右反転 ImageUtil.withStreams(file) { BufferedImage bufReader, BufferedImage bufWriter -> Integer width = bufReader.width Integer height = bufReader.height Integer maxIndexX = width - 1 // for each line(y) (0..<height).each {Integer y -> // for each columns(x) (maxIndexX..0).each {Integer x -> Integer colorInfo = bufReader.getRGB(x, y) bufWriter.setRGB(maxIndexX - x, y, colorInfo) } } ImageUtil.save(bufWriter, "${dir}/reversal.jpg") }
縦に分割
画像を縦に5つに分割して、最後の分割分から順番に左から描画します。
String dir = "/home/koji/work/groovy/image-processing" String fileName = "test.jpg" File file = new File("${dir}/${fileName}") ImageUtil.withStreams(file) { BufferedImage bufReader, BufferedImage bufWriter -> Integer width = bufReader.width Integer height = bufReader.height Integer splitCount = 5 List <List<Integer>>splited = [] Integer splitedWidth = width / splitCount Integer remainingPixels = width % splitCount Closure getIndexes = {Integer rowIndex -> Integer startIndex = (splitedWidth - 1) * rowIndex + rowIndex Integer endIndex = startIndex + (splitedWidth - 1) [startIndex, endIndex] } splitCount.times {Integer rowIndex -> List<Integer> rowPixcels = [] def (startIndex, endIndex) = getIndexes(rowIndex) (0..<height).each {Integer y -> (startIndex..endIndex).each {Integer x -> rowPixcels << bufReader.getRGB(x, y) } } splited << rowPixcels } // 分割した時に、余っている部分があれば救済 // 今回はとりあえず最後の分割画像に余りを単純に追加 if (remainingPixels > 0) { Integer maxIndexX = width - 1 Integer remainIndexX = remainingPixels -1 (0..<height).each {Integer y -> (0..remainIndexX).each {Integer x -> splited.last() << bufReader.getRGB(width -1 - x, y) } } } // リスト内容を反転させて描画 splited.reverse().eachWithIndex {List<Integer> column, rowIndex -> def (startIndex, endIndex) = getIndexes(rowIndex) (0..<height).each {Integer y -> (startIndex..endIndex).eachWithIndex {Integer x, Integer index -> def path = index + ((splitedWidth) * y) bufWriter.setRGB(x, y, column[path]) } } } ImageUtil.save(bufWriter, "${dir}/split.jpg") }
以上です。
画像を扱うコード自体はなんだかオールドスクールな感じになりました。
AIだ機械学習だという流れの昨今、画像処理に関する知識も求められるようになっていますので、最も基礎的なピクセル単位での画像の取り扱いをまとめてみました。
Rustでcatコマンドを実装
Rustでcatコマンドを実装しました。
ちゃんと標準入力からでも引数で指定されたファイルからでも読み込めます。
ミソになるのが、stdio
でもFile
でもBufRead
というトレイトを実装しているので、そのトレイトの型を受け取って共通の処理が実現出来る点です。
use std::io::BufReader; use std::io::BufRead; use std::io::Stdin; use std::fs::File; use std::env; fn main() { println!("cat implimented by Rust"); let args: Vec<String> = env::args().collect::<Vec<String>>(); // 標準入力から読み込み if args.len() <= 1 { let stdin: Stdin = std::io::stdin(); // Readトレイトを実装している構造体であればBufReader::newに渡せる。 let mut buf_file = BufReader::new(stdin); do_cat(&mut buf_file); // 引数で指定されたファイル(1つ以上) } else { args[1..].iter().for_each(|file_name| { match File::open(file_name) { Ok(file) => { // Readトレイトを実装している構造体であればBufReader::newに渡せる。 let mut buf_file = BufReader::new(file); do_cat(&mut buf_file); } Err(e) => println!("{}: {}", file_name, e), } }) } } fn do_cat(stream: &mut BufRead) -> () { let mut buffer = String::new(); loop { match stream.read_line(&mut buffer) { Ok(0) => break, // EOF Ok(_) => { print!("{}", buffer); buffer.clear(); } Err(e) => { println!("{}", e); break; } } } }
Apache GroovyでSSL証明書の有効期限を確認する
本記事はG* Advent Calendar 2017の19日目の記事です。
さて、2016年から正式に公開されたLet's Encryptを利用することで、誰でも無料でSSLを導入してセキュアな通信をお手軽に導入できるようになりました。
しかしLet's Encryptの証明書の有効期限は3ヶ月となっており、油断していると有効期限が切れていてエラーが表示されるようになりかねません。
openssl
コマンド等を利用すればSSLの有効期限等が取得できるので、Zabbixなどで定期的に実行、確認してあげるようにする必要が有ります。
しかしWindowsだとopensslコマンドが。。。などなど問題が有りますので、今回Apache GroovyでSSLの有効期限を取得するスクリプトを書いてみました。
これであればLinuxでもWindowsでApache Groovyが動くようにしておけば問題なく実行することが出来ます。
import java.security.cert.X509Certificate; import javax.net.ssl.HttpsURLConnection; import java.security.cert.Certificate; import java.security.Principal // SSL情報を確認したいサーバに接続 URL url = new URL("https://www.example.com") HttpsURLConnection con = url.openConnection() con.connect() // サーバの主体(?)を取得する。 // 下記のgetServerCertificates()では複数の証明書が返ってくるので、このPeerPrincipalのhashCodeと比較して実際のSSL証明書情報を取得するために利用する。 Principal peerPrincipal = con.getPeerPrincipal() // サーバーの証明書チェーンを取得する List<Certificate> certs = con.getServerCertificates() Certificate certificate = certs.findAll { it instanceof X509Certificate }.find { it.getSubjectDN().hashCode() == peerPrincipal.hashCode() } // あとはCertificateクラスのメソッドなどを利用してSSL情報を取得できる。以下が有効期限の取得方法。 Date expirationDate = certificate.getNotAfter() println expirationDate con.disconnect()
すこし手こずった部分が、サーバから返ってくる証明書が複数あるので、どの証明書の有効期限を確認すれば良いか、という部分です。
コメントに書いてありますが、実際に利用されるSSL証明書の情報はgetPeerPrincipal()
で取得できるので、サーバから返される証明書の一覧から、これと一致するものを取得するようにする必要が有ります。
なお、Principal#getName()
で比較することも出来ますが、これだと同じ内容でもgetPeerPrincipal()
で取得するPrincipal
と証明書一覧からそれぞれ取得するPrincipal
で文字列内のスペースがtrimされていたりして上手く比較できるパターンと出来ないパターンが混在しています。
そのため、比較はhashCode()
で行うようにしています。
Java標準の組み込みWebサーバをApache Groovyで利用する
本記事はG* Advent Calendar 2017の18日目の記事です。
@tyamaさんのブログで紹介されていた、@glaforgeさんのこのツイートで初めて、Javaに組み込みWebサーバが存在していることを知りました!
ツイート内のコードだとシングルスレッド(HttpServer#start()を実行したスレッド)で全てのアクセスが同期して処理されるらしいので、事前にExecutorを定義してHttpServer#setExecutor()
で渡してあげてマルチスレッドでWebサーバを動かすようにしてみました。
import com.sun.net.httpserver.HttpServer import com.sun.net.httpserver.HttpExchange import java.util.concurrent.Executor class DirectExecutor implements Executor { public void execute(Runnable r) { // r.run() <--- これを実行すると同期処理にすることも出来る。 new Thread(r).start() } } HttpServer.create(new InetSocketAddress(8081), 0).with { createContext("/hello") { HttpExchange http -> println """ request method: ${http.requestMethod} protocol: ${http.protocol} remoteAddress: ${http.remoteAddress} headers: ${http.requestHeaders} URI: ${http.requestURI} body: ${http.requestBody} """ http.responseHeaders.add("Content-type", "text/plain") http.sendResponseHeaders(200, 0) http.responseBody.withWriter {out -> out << "Hello ${http.remoteAddress.hostName}" } } executor = new DirectExecutor() start() }
コレを適当なファイル名で保存しておいて、groovyコマンドで実行するだけでWebサーバの完成です。
これで、
curl -X POST http://localhost:8081/hello?a=b --data "hoge" --data "{a:b, c:d}"
というリクエストを投げると、コンソールには
request method: POST protocol: HTTP/1.1 remoteAddress: /127.0.0.1:42410 headers: [Accept:[*/*], Host:[localhost:8081], User-agent:[curl/7.35.0], Content-type:[application/x-www-form-urlencoded], Content-length:[15]] URI: /hello?a=b body: hoge&{a:b, c:d}
と出力されます。
ちょっとしたツールとかサービスなんかは、古き良き時代(?)のPHPスクリプトのように、Webサーバに結びついていてPHPスクリプトをその場で修正すれば即反映される、というような気軽な感じで作りたいという欲求が合ったので、すでに今回のG* Advent Calendar 2017で投稿したGroovyだけでWebサーバを実装する方法を応用すれば、このJava組み込みWebサーバでGroovyスクリプトを動かせるな〜とちょっとワクワクしています。
Apache Groovy "as" オペレータの正体
これはG* Advent Calendar 17日目の記事です。
ある値を型Aから型Bに変換するといったコードは頻繁に出てきますよね。
キャストで行えるものが最もシンプルですが、キャストだと変換できないパターンというものが大量に存在します。
例えば以下のコードはそれぞれ例外が発生します。
Integer a = "123" Integer a = (Integer) "123"
例外の内容は共に、キャストできません、というものです。
org.codehaus.groovy.runtime.typehandling.GroovyCastException: Cannot cast object '123' with class 'java.lang.String' to class 'java.lang.Integer'
さて、そこで今回のネタである強制変換オペレータas
を使ってみます。
Integer a = "123" as Integer
なんと、こちらは問題なく動作しました。
ということは、どうやらキャストとasオペレータは別物のようです。
asの正体
このasオペレータ、実はレシーバに定義されているasType
というメソッドを実行しています。
このasType()はクラス自体を引数として取り、その指定されたクラスを元にどんな変換処理をするか、という処理を定義することが出来ます。
なので、上記のコードは
Integer a = "123".asType(Integer)
と等価です。
つまり上記のコードは、"123"
というStringクラスのインスタンスなので、Stringクラスに定義されているasType
メソッドが実行されて、その中でIntegerの値を生成してreturnしてくれている、ということになります。
具体例
class Person { String name Integer age String toString() { "Person name:${name}, age:${age}" } // これ! def asType(Class convertTo) { // 別にクラスは一つだけじゃなくて、複数指定することも出来る。 switch (convertTo) { case Developer: return new Developer(person: this, language: "Apache Groovy") break; case Integer: return age default: throw new ClassCastException("Person cannot be coerced into ${convertTo}") } } } class Developer { Person person String language String toString() { "${language} Developer!! ---> ${person}" } } Person p = new Person(name: "koji", age: 32) // 継承関係も何も無いクラスへ変換! Developer d = p as Developer assert p instanceof Person assert !(p instanceof Developer) assert d instanceof Developer assert !(d instanceof Person) assert p.toString() == "Person name:koji, age:32" assert d.toString() == "Apache Groovy Developer!! ---> Person name:koji, age:32" assert 32 == p as Integer // 参照を持っているのでメンバ変数を変更すると両方変る。 p.age = 99 assert p.toString() == "Person name:koji, age:99" assert d.toString() == "Apache Groovy Developer!! ---> Person name:koji, age:99" // Integerにもasで変換できるようにしている。 assert 99 == p as Integer
Groovyにもtraitが追加された今では、asTypeで実現できることはtraitでも出来るような気がするので、自分のコードでasTypeを定義する機会はあまり無いな気もしますが、普段何気なく使っているasオペレータの動作を知ることで一歩Groovyの深みに近づけた感じです。
Grails/Vue.jsでTodoアプリ(Deploy as war)
これはG* Advent Calendar 2017の11日目の記事です。
この記事は、以下の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)
初めに
Webサーバ(今時で言うフロントエンド)とApplicationサーバ(Grails)を分けて、それぞれ別々にデプロイするのであれば特に考慮することはありません。
が、小さなWebアプリなどをVue.js&Grailsで開発するのであれば、Grailsのwarで一緒に簡単一発デプロイ出来た方が素敵です。
コレができればもうWebサーバもApplicationサーバも必要なく、javaコマンド一発でVue.jsとGrailsを駆使したWebアプリケーションを公開することが出来ます!
そのための手順をココでご紹介します。
前提条件
Grailsで作成したアプリーションのディレクトリは今後$GRAILS_PROJECT
とし、Vue.jsのアプリケーションのディレクトリはその直下なで$VUE_PROJECT
と記述します。
私の環境では、$GRAILS_PROJECT
は/home/koji/work/grails/gvtodo
で、$VUE_PROJECT
は$GRAILS_PROJECT/frontend
になっています。
また、上記の4つの記事で作成してきたアプリケーションをベースとして本文を記述しています。
Grails側の設定
さて、Vue.jsをGrailsのwarに含めるために、幾つかGrails側で設定して上げる必要が有ります。
なぜかというと、開発時はGrailsをポート8080、Vue.jsアプリをポート8081で捌いていたので問題ありませんでしたが、Vue.jsもGrailsのwarに含めるということはWebもAppも両方同じポート(今回は8080。変更可)で動くことになります。
そのため、Grailsが提供するAPI以外へのアクセスは全て、SPAであるVue.jsに流して上げる必要が有ります。
Vue.jsにアクセスを流すコントローラの作成
専用のAPI以外へのアクセスをVue.jsで作成したSPAにそのまま流してあげるために、専用のControllerを1個追加します。名前は何でも良いので、今回はIndexとしました。
grails> create-controller index | Created grails-app/controllers/gvtodo/IndexController.groovy | Created
IndexController.groovyの中身は以下のようになります。
package gvtodo class IndexController { def index() { // webappから読み取り。develop時は問題ないけど、warでのproductionモードだと見つからない。 // render file: grailsApplication.mainContext.getResource("index.html").file // render file: grailsApplication.classLoader.getResourceAsStream('index.html').text // render file: // ということで、Vue.jsのindex.htmlはsrc/main/resourcesに格納しておいてそこから読み込むようにする必要が有る。 response.contentType = 'text/html' response.outputStream << grailsApplication.classLoader.getResourceAsStream('index.html') response.outputStream.flush() } }
そして、API以外の全てのアクセスはこのIndexControllerのindexアクションに流すように、UrlMapping.groovyを変更します。
package gvtodo class UrlMappings { // これを追加 // src/main/webappディレクトリ用に、/staticへのアクセスはこのUrlMappingでは処理しない static excludes = ["/static/**"] static mappings = { "/manual/items"(resources: 'item') "/$controller/$action?/$id?(.$format)?"{ constraints { // apply constraints here } } // "/"(view:"/index") コメントアウト "/**"(controller: "index", action: "index") // これを追加 "500"(view:'/error') "404"(view:'/notFound') } }
Vue.jsのために認証の設定を修正
すでにAPI自体はSpringSecurityCore
とSpringSecurityREST
両プラグインによってログインしていなければ利用できないようになっています。
APIに該当しないURLの場合は全てVue.jsに流すようにしますので、Vue.jsへのアクセス自体に認証をかけてしまうと誰も使えなくなってしまいます。
ということで、$GRAILS_PROJECT/grails-app/conf/application.groovy
を以下のように一行だけ修正します。
修正箇所はinterceptUrlMap内の一番最後のpattern: '/**'
の部分のみです。
// Added by the Spring Security Core plugin: ...変更ないので省略... 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']], // この1行だけ修正した。 // Vue.jsがこれに該当するので、コレ自体は誰でもアクセスOKにする。 [pattern: '/**', access: ['permitAll']] ] grails.plugin.springsecurity.filterChain.chainMap = [ ...変更ないので省略 ]
本番用DBの設定(今回の記事用)
warでGrailsを動作させるとデフォルトで本番用モードで起動します。
そうするとDB自体は事前にスキーマやデータ等を用意しておかないといけないので、ちょっと今回はその手間を省くために、productionモードでもdbCreateをcreate-dropにしておきます。
$GRAILS_PROJECT/grails-app/conf/application.yml
...省略... environments: development: dataSource: dbCreate: create-drop url: jdbc:h2:mem:devDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE test: dataSource: dbCreate: update url: jdbc:h2:mem:testDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE production: dataSource: # dbCreate: none dbCreate: create-drop # これに変更。あくまで今回の確認用なので注意! url: jdbc:h2:./prodDb;MVCC=TRUE;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE ...省略...
これでGrails側の設定は完了です。
Vue.js側の設定
ビルド設定の修正
続いて、Vue.jsを本番用にビルドする際の設定を修正します。
修正するファイルは、$VUE_PROJECT/config/index.js
です。
'use strict' // Template version: 1.1.3 // see http://vuejs-templates.github.io/webpack for documentation. const path = require('path') module.exports = { build: { env: require('./prod.env'), // index: path.resolve(__dirname, '../dist/index.html'), // assetsRoot: path.resolve(__dirname, '../dist'), index: path.resolve(__dirname, '../../src/main/resources/index.html'), // 追加 assetsRoot: path.resolve(__dirname, '../../src/main/webapp'), // 追加 assetsSubDirectory: 'static', // assetsPublicPath: '/', assetsPublicPath: '/static/', // 追加 productionSourceMap: true, // Gzip off by default as many popular static hosts such as // Surge or Netlify already gzip all static assets for you. // Before setting to `true`, make sure to: // npm install --save-dev compression-webpack-plugin productionGzip: false, productionGzipExtensions: ['js', 'css'], // Run the build command with an extra argument to // View the bundle analyzer report after build finishes: // `npm run build --report` // Set to `true` or `false` to always turn it on or off bundleAnalyzerReport: process.env.npm_config_report }, dev: { env: require('./dev.env'), port: process.env.PORT || 8081, autoOpenBrowser: true, assetsSubDirectory: 'static', assetsPublicPath: '/', proxyTable: {}, // CSS Sourcemaps off by default because relative paths are "buggy" // with this option, according to the CSS-Loader README // (https://github.com/webpack/css-loader#sourcemaps) // In our experience, they generally work as expected, // just be aware of this issue when enabling this option. cssSourceMap: false } }
build:
の中のindex:
、assetsRoot:
、assetsPublicPath:
を修正しました。(既存のものをコメントアウトして新規に追加)
ビルドすると、index.html
はGrailsのresourcese
ディレクトリへ、それ以外のファイル(JavaScript、CSS、画像等)はGrailsのwebapp
ディレクトリ配下にコピーするようにしました。
本当は単純に全てwebapp
にindex.html
も含め、Vue.js系のファイルを放り込めればいいのですが、そうすると/
でアクセスしてきた時に、GrailsのControllerがindex.html
をrenderしようとしてもファイルが上手く見つからないなどの問題が発生しました。
そのため、細かく全ての問題をメモし忘れてしまったのですが、現状一番確実な方法はこれとなります。
URLの設定(必須ではない)
そして最後に、必須ではないのですが、$VUE_PROJECT/src/router/index.js
内の、new Router
の部分を以下のように修正します。
const vr = new Router({ mode: 'history', // これを追加してURLのハッシュ記号(#)を取る routes: [ ...省略... { // 本当にAPI以外の全てのアクセスがVue.jsに流れてきているか確認するために以下を追加 path: '/a/b/c', name: 'ToDoIndex2', component: ToDoIndex } ...省略...
warに固める
いよいよwarに固めてみます。
まず、yarn run build
で、必要なファイルをビルドしてGrailsのディレクトリに配置します。(この設定を上記のindex.jsで行いました)
[koji:frontend]$ yarn run build yarn run v1.3.2 $ node build/build.js Hash: a013ba3d49397af14f5f Version: webpack 3.8.1 Time: 11975ms Asset Size Chunks Chunk Names static/js/vendor.077af75171293270864c.js 136 kB 0 [emitted] vendor static/js/app.8ddd9f2f6956595dcdf6.js 15.1 kB 1 [emitted] app static/js/manifest.fffa9c3719c553c5d3cf.js 1.49 kB 2 [emitted] manifest static/css/app.0d9dd6962ae03b505f9fdae53bae9d85.css 192 bytes 1 [emitted] app static/js/vendor.077af75171293270864c.js.map 1.1 MB 0 [emitted] vendor static/js/app.8ddd9f2f6956595dcdf6.js.map 65.9 kB 1 [emitted] app static/js/manifest.fffa9c3719c553c5d3cf.js.map 14.3 kB 2 [emitted] manifest ../resources/index.html 471 bytes [emitted] Build complete. Tip: built files are meant to be served over an HTTP server. Opening index.html over file:// won't work. Done in 16.04s. [koji:frontend]$
そしてGrailsは普通にwarコマンドを実行するだけです。
grails> war :compileJava UP-TO-DATE :compileGroovy :findMainClass :assetCompile Processing File 1 of 25 - apple-touch-icon-retina.png Processing File 2 of 25 - favicon.ico ...省略... :bootRepackage :assemble BUILD SUCCESSFUL Total time: 12.179 secs | Built application to build/libs using environment: production grails>
Gradleの機能を使えば普通に1コマンドで出来るようになると思いますが、実はGradleはほぼまともに触ったことが無いので今回はスルー. これで完了です。
warを実行する
warファイルは$GRAILS_PROJECT/build/libs/gvtodo-01.war
として作成されています。
実際にこれを適当なディレクトリに持って行って実行してみます。
[koji:gvtodo]$ mv build/libs/gvtodo-0.1.war $HOME/Desktop [koji:gvtodo]$ cd $HOME/Desktop [koji:Desktop]$ java -jar gvtodo-0.1.war Configuring Spring Security Core ... ... finished configuring Spring Security Core Configuring Spring Security REST 2.0.0.M2... ... finished configuring Spring Security REST Grails application running at http://localhost:8080 in environment: production
起動しました。 これで、http://localhost:8080/や、http://localhost:8080/a/b/cにアクセスすれば、ちゃんとwarの中でVue.jsが動いていることが確認できるはずです。
どんな時にこんなとするの?
さて、今回態々Grailsのwar自体にVue.jsをまるまる放り込みましたが、そもそもフロントエンドとバックエンドはAPIを通してやり取りする粗結合なものにするのが世の流れなので、今回のようにwarの中にフロントエンドであるVue.jsまで含めてしまうのはあまり良い考えでは無いと思います。
ちょっとしたHTMLの修正もGrails(Tomcat)の再起動が必要にもなります。
ではいつ今回の内容が役立つかというと、それは恐らくGrails製アプリケーションをOSSとして公開、配布するタイミングだと思われます。
ユーザには単純にwarファイルを落としてもらって、javaコマンド一発でWebアプリケーションが起動する、という手軽さを提供することが出来ます。JenkinsやGitbucketと同じ手法です。
かく言う私も現在、この方法を用いたGrails製のOSSアプリケーションを作成中です。
本来は今年の夏頃に公開したかったのですが中々思うように時間をとれずに公開までには至っていません。
自分が仕事で使いたいツールだったので、すでに一応個人的に仕事で導入して利用しているので、来年のアドベントカレンダーの頃には公開出来れば良いな〜と思っています。
Grails/Vue.jsでTodoアプリ(認証) 2/2
これはG* Advent Calendar 2017の10日目の記事です。
この記事は、以下の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で利用できる認証機構を追加しました。
今日はさらに追加で、アクセストークンの更新方法とログアウト、そしてSpringSecurityCoreとSpringSeucirtyRESTの組合せで発生するちょっとした問題の回避方法ご紹介します。
前提条件
Grailsで作成したアプリーションのディレクトリは今後$GRAILS_PROJECT
とし、Vue.jsのアプリケーションのディレクトリはその直下で$VUE_PROJECT
と記述します。
私の環境では、$GRAILS_PROJECT
は/home/koji/work/grails/gvtodo
で、$VUE_PROJECT
は$GRAILS_PROJECT/frontend
になっています。
access_tokenのリフレッシュ
なぜ?
SpringSecurityRESTがクライアントに発行してくれるaccess_token
を使って、認証/認可が出来るようになりました。
しかし、実はこのaccess_token
、デフォルトの設定だと3600秒(1時間)しか有効期限がありません。
1時間経過した後に、その有効期限の切れたaccess_token
を使ってRESTful APIにアクセスすると、HTTPレスポンスコード401が返されます。(つまりログインしていないのと実質同じ)
ではどうすれば良いのか?
単純にトークンの有効期限を伸ばすことも可能です。
その場合、application.groovy
内に
grails.plugin.springsecurity.rest.token.storage.jwt.expiration = 秒数
と指定することで可能です。ただ、コレでもやはり根本的な問題の解決ではありません。
SpringSecurityRESTでは、refresh_token
を使って、新しいaccess_token
を取得することが可能です。
access_token
もrefresh_token
も、ログイン成功時にSpringSecurityRESTから渡されています。
なお、すでに述べた通り、access_token
には制限期間があって、application.groovy
でその値を変更することが出来ますが、このrefresh_token
には制限期間はありません。
リフレッシュ方法
実際にaccess_token
をリフレッシュするには、/oauth/access_token
に、POSTメソッドでリクエストを投げます。
送信しなければならないデータは、grant_type
と、ログイン時に渡されたrefresh_token
の2種類だけです。
const url = `http://localhost:8080/oauth/access_token` // Content-Type: application/x-www-form-urlencodedで送らないとダメなので、そのためにURLSearchParamsを使って構築。 // Vue.jsではくて、axiosでそれを実現する方法 const params = new URLSearchParams() params.append('grant_type', 'refresh_token') params.append('refresh_token', UserInfoRepository.get().getRefreshToken()) axios.post(url, params) .then((response) => { // 新しいaccess_tokenを含む認証情報が再度得られるので、ココで再保存。 UserInfoRepository.save(response.data) })
上記のようコードを実行することで、新しいaccess_token
が取得できるので、それを利用することでユーザは常にログインしている状態に出来ます。
しかし、もし上記ようにaccess_token
のリフレッシュをリクエスト時に確認してしまうと、その画面で1時間放置しておいてToDoの追加ボタンを押すと、当然その際にリクエストヘッダーに付与されるaccess_token
はSpringSecurityREST側ではタイムアウト扱いになり、401が返されます。
その場合は、一旦画面をリフレッシュすれば新しいaccess_token
再セットされますが、スマートではないですよね。
ということで、ToDoのRESTful APIを実行する前に必ずこのaccess_token
をリフレッシュする処理を実行するように、以下のようにコードを変更してみました。
$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: { api: function (func) { const url = `http://localhost:8080/oauth/access_token` // Content-Type: application/x-www-form-urlencodedで送らないとダメなので、そのためにURLSearchParamsを使って構築 const params = new URLSearchParams() params.append('grant_type', 'refresh_token') params.append('refresh_token', UserInfoRepository.get().getRefreshToken()) axios.post(url, params) .then((response) => { // 新しいaccess_tokenを含む認証情報が再度得られるので、ココで再保存。 UserInfoRepository.save(response.data) func() }) }, list: function () { this.api(() => { axios.get(`${baseURL}`, {headers: UserInfoRepository.get().generateAuthHeader()}) .then((response) => { this.todoList = response.data }).catch(errorFunc) }) }, add: function () { this.api(() => { axios.post(`${baseURL}`, { title: this.title }, {headers: UserInfoRepository.get().generateAuthHeader()}) .then((response) => { this.title = '' this.list() }) .catch(errorFunc) }) }, edit: function (event, id) { this.api(() => { axios.put(`${baseURL}/${id}`, { title: event.target.value }, {headers: UserInfoRepository.get().generateAuthHeader()}) .then((response) => { this.list() }) .catch(errorFunc) }) }, remove: function (id) { this.api(() => { axios.delete(`${baseURL}/${id}`, {headers: UserInfoRepository.get().generateAuthHeader()}) .then((response) => { this.list() }) .catch(errorFunc) }) } } } </script> <style> .todo-title { border: none; } </style>
新たにapi
関数を用意して、この中でaccess_tokekn
をリフレッシュしています。
そして各RESTful APIにアクセスする処理自体を無名関数化して、それを今回用意したこのapi
関数の中で実行するようにしました。
これで常に新しいaccess_token
が得られるようになりました。
UserInfoRepositoryを使って新しいaccess_token
を含むレスポンスデータをlocalStorageに再保存していますので、その後実行する、渡された関数内では、UserInfoRepositoryは当然その新しいaccess_token
を返してくれるので、ToDo用のRESTful APIには常に新しいaccess_token
が利用できる、という流れになっています。
当然この方式は私がこの記事のために実装しただけなので、要件によって実装方法などは変わります。
これで、ユーザが意図的にlocalStorageを削除しない限り常にログイン状態を保てるようになりました。
ログアウト
永続的にログイン状態に出来るようになりました。
では当然必要になってくるログアウトも実装しましょう。
が、SpringSeurityRESTの公式ドキュメントを読むとデフォルトのJWTはそもそもステートレスなんだからサーバは状態を保持していない、つまりログアウトなんて不可能だよ!とのことです。
しかしどうしてもユーザにログアウトさせてあげたい、というのであれば単純にlocalStorageに保存している情報を削除すれば、access_token
もrefresh_token
もユーザは参照できなくなり、結果的にログアウトしたことに出来ます。
ということでやることはとっても簡単!
今回は追加箇所のみ抜粋します。
$VUE_PROJECT/src/components/todo/Index.vue
<template> <div> <h1>ToDoリスト</h1> ...省略... <button @click="remove(todo.id)">del</button> <!--この1行をを追加--> <hr><a @click="logout">Logout</a> </li> </ul> </div> </template> <script> ....省略.... methods: { ...省略... logout: function () { UserInfoRepository.logout() this.$router.push({name: 'Login'}) } } } </script>
これだけです!
ToDo一覧の一番下にログアウトリンクを追加したので、それをクリックすると単純にUserInfoRepository.logout()
を実行して、ログインページにリダイレクトします。
このUserInfoRepository.logout()
は、単純にlocalStorageからデータを削除しているだけです。
static logout () { localStorage.removeItem('userInfo') }
これでユーザは実質ログインしたのと同じことになります。簡単ですね!
Grails側でユーザの情報を更新する際のワークアラウンド
さて、Grails側ではSpringSecurityCoreで各ユーザのステータスを管理できます。
デフォルトでは、SpringSecurityCoreのユーザ用のドメイン(今回のサンプルではAuthUser.groovy)を見ると
boolean enabled = true boolean accountExpired boolean accountLocked boolean passwordExpired
が用意されています。
例えば、あるユーザのenabled
をfalse
にすれば、そのユーザを今後ログイン出来なくすることが出来ます。
太字にした通り、実はそこに大きな落とし穴が有ります。
SpringSecurityCoreプラグインでは、上記の4つのユーザのステータスを更新(直接SQLでデータを更新でも)しても、その時点のログイン状態には影響せず、次回ログイン時にそのステータスをチェックするようになっています。
つまり今回のSpringSecurityRESTベースで、毎回自動的にrefresh_token
を使ってaccess_token
を更新する方式だと、BANさせたいユーザが居たとしても、永久にそのユーザがログインした状態になってしまいます。
実際に、GrailsのDBConsoelにアクセスして、以下のSQLを実行してみてください。
UPDATE AUTH_USER SET enabled = false WHERE id = 1;
間違いなくDB上のデータは更新されるのですが、すでにログインしているユーザは引き続き操作を問題なく行えてしまいます。
これは非常に不味いです。恐らくSpringSecurityCoreのinterface等を実装した自前の認証方法などを用意すれば回避することも出来そうですが、自分でそこまではたどり着けませんでした。
ということで、その問題を回避するワークアラウンドをご紹介します。
特に難しいことは全くなくて、単純にGrails3のInterceptor
で、毎アクセスごとにそのユーザのステータスをチェックして、ステータスによって403を返したりするだけです。
まずはInterceptor
を適当な名前で作成します。
grails> create-interceptor CheckUserStatusInterceptor | Created grails-app/controllers/gvtodo/CheckUserStatusInterceptor.groovy | Created src/test/groovy/gvtodo/CheckUserStatusInterceptorSpec.groovy
作成されたInterceptor
を以下のようにします。
package gvtodo import groovy.json.JsonBuilder import org.springframework.dao.DataAccessResourceFailureException class CheckUserStatusInterceptor { def springSecurityService CheckUserStatusInterceptor() { matchAll() .excludes(uri: '/login/**') .excludes(uri: '/oauth/access_token/**') } boolean before() { try { AuthUser user = springSecurityService.currentUser String errorMessage if (user != null && !user.enabled) { errorMessage = 'Sorry, your account is disabled.' } if (user?.accountLocked) { errorMessage = 'Sorry, your account is locked.' } if (user?.passwordExpired) { errorMessage = 'Sorry, your password has expired.' } if (user?.accountExpired) { errorMessage = 'Sorry, your account has expired.' } if (errorMessage) { response.status = 403 JsonBuilder json = new JsonBuilder() json([message: errorMessage]) render json return false } true } catch (DataAccessResourceFailureException e) { // What is this!!?? // If user has no Authority, SpringSecurityCore returns 403.(Please check application.groovy) // But this interceptor is executed too! // This exception is thrown if be used springSecurityService when user has no authority. // Grails/SpringSecurityCore already preparation to return a 403 status to client. // There fore it return true simply here it is enough. // Could not obtain current Hibernate Session; nested exception is org.hibernate.HibernateException: No Session found for current thread true } } boolean after() { true } void afterView() { // no-op } }
以上です!ココで一旦Grailsを再起動しておいてください。
この方法で全てのリクエストでユーザの情報を必ずチェックできるようになったので、ユーザがすでにログインしている状態で、再度GrailsのDBConsoelにアクセスして、以下のSQLで状態を更新します。
UPDATE AUTH_USER SET enabled = false WHERE id = 1;
これでログインしているユーザのToDo一覧画面でページをリロードすると、403が返り、ユーザはもうToDoにアクセスできなくなっています。
注意点
いかがだったでしょうか?個人的にオールドスクールなSpringSecurityCoreのみで認証するモノリシックなシステムを開発運用してきたので、中々今回のRESTful APIベースでの認証には手こずりました。。。
さて、前回も述べましたが、今回実装してはっきりわかると思いますが、トークン(access_tokenかrefresh_token)が第3者に漏れるとアカウントがっ取られます。
ただのHTTPヘッダーにトークンを埋め込むわけですので、非SSL環境では絶対に利用するべきではありません!
幸いなことに、現在はLet's Encryptのように無料で利用できるSSLがあります。必ずSSL経由で認証を行うようにしましょう!