本記事は Vim その2 アドベントカレンダー 20日目の記事です。
先日、AWS re:Invent 2018 にてAWS Lambda上でCustom Runtimeが発表されました。
弊社でも早速Rustの公式ランタイムを使ってみたりして、いろいろな言語で使える展望を感じてワクワクしていました。
でも、せっかくいろいろなものが動かせるのなら、どうせならあんまり意図されていないようなものに挑戦してみたほうがCustom Runtimeへの理解も深まるかなぁと思い、今回はVimをCustom Runtime上に設置して、Vim scriptでRuntimeのハンドラーを書けるようにならないかどうか挑戦してみて、一応Vim scriptでハンドラーをかいてLambdaのレスポンスとして返せるようになりました。
成果物:
https://github.com/pocket7878/aws-lambda-vim-layer
サンプルコード:
https://github.com/pocket7878/aws-lambda-vim-layer-example
Custom Runtimeのざっくりした構造
Custom Runtimeは、適切なディレクトリに実行したいバイナリやライブラリとbootstrap
というエントリの実行ファイルを配置したzipファイルで、bootstrap
ファイルがLambdaの起動時に自動的に呼び出されるという仕組みのものです。
このbootstrap内で、今回発表されたRuntime APIからイベントをGETして、結果を結果投稿用のAPIにPOSTしてやるようなループ処理ができればどんなものでもLambdaのRuntimeとして利用できます。
Vimと依存ライブラリの取得
今回はVimをLambdaの環境で動かしたいため、lambci/lambdaのDocker Imageを利用してAWS Lambdaの内部環境を再現した環境で動くVim及びVimの実行に必要なライブラリの取得を行っています。
仕組みとしては単純にlambciベースのDocker Imageの中でvimをパッケージ経由でインストールしてやり、そのvimのバイナリとvimの依存しているライブラリ等を起動時にマウントしておいたホストのディレクトリにコピーしてきています。
(ここはvimのソースを実際にダウンロードしてきてコンパイルしてくる形にして、外部からビルドオプション等を指定できるようにしたいと思っていますが、今回はまずは完動を目指して後回しにしています)
vimrcの作成
vimをコピーしてきただけではまだvimのバイナリが入ったLambda Layerにしかならないので、
bootstrapファイルを作成して、指定されたVim scriptファイルを読み込んで、ハンドラー関数からの結果を返せるようにしなければなりません。そこで今回は、LambdaのRuntimeAPIを叩くループを行う関数を起動直後に呼び出すvimrcファイルを作成し、そのvimrcを指定してvimを起動する処理をbootstrapファイルに書いています。
vim -u /opt/vim/vimrc
function! LambdaHandler()
AWS Lambdaのハンドラ指定は<ハンドラファイル名>.<関数名>
という形式になっているため、
ディレクトリ内の<ハンドラファイル名>.vim
というディレクトリをsourceしてやって、
whileループでひたすらRuntime APIのイベントGETのエンドポイントを叩いてはイベントデータとコンテキストを関数に渡してやって、
返ってきた結果をまたRuntime APIの結果を返すエンドポイントにPOSTしてやるという関数を定義しています。
function! LambdaHandler()
let V = vital#lambda_vim_layer#new()
let HTTP = V.import('Web.HTTP')
let JSON = V.import('Web.JSON')
let handlerInfo = split($_HANDLER, '\.')
let handlerFile = $LAMBDA_TASK_ROOT . "/" . handlerInfo[0] . ".vim"
let handlerMethod = handlerInfo[1]
if filereadable(expand(handlerFile))
execute 'source ' . fnameescape(handlerFile)
endif
while 1
let nextEvent = HTTP.get("http://".$AWS_LAMBDA_RUNTIME_API."/2018-06-01/runtime/invocation/next")
let eventData = JSON.decode(nextEvent.content)
let eventHeader = HTTP.parseHeader(nextEvent.header)
let eventContext = {
\'memoryLimitInMb' : $AWS_LAMBDA_FUNCTION_MEMORY_SIZE,
\'functionName' : $AWS_LAMBDA_FUNCTION_NAME,
\'functionVersion' : $AWS_LAMBDA_FUNCTION_VERSION,
\'invokedFunctionArm' : eventHeader['Lambda-Runtime-Invoked-Function-Arn'],
\'xrayTraceId' : eventHeader['Lambda-Runtime-Trace-Id'],
\'awsRequestId' : eventHeader['Lambda-Runtime-Aws-Request-Id'],
\'logStreamName' : $AWS_LAMBDA_LOG_STREAM_NAME,
\'logGroupName' : $AWS_LAMBDA_LOG_GROUP_NAME,
\'clientContext' : JSON.decode(get(eventHeader, 'Lambda-Runtime-Client-Context', '{}')),
\'identity' : JSON.decode(get(eventHeader, 'Lambda-Runtime-Cognito-Identity', '{}')),
\}
let requestId = eventHeader['Lambda-Runtime-Aws-Request-Id']
let result = function(handlerMethod)(eventData, eventContext)
call HTTP.post("http://".$AWS_LAMBDA_RUNTIME_API."/2018-06-01/runtime/invocation/".requestId."/response", result)
endwhile
endfunction
LambdaHandler()ではWebのAPIを叩くことになるのでvital.vimを使いたかったので、
zipのなかにvital.vimを適用したプラグインの入ったbundleディレクトリを置いておいてやり
execute 'set rtp+=' . expand('<sfile>:p:h') . '/bundle'
のようにしてruntimepathに追加しています。
この関数がvimの起動直後に呼び出されるようにしたいので、
autocmd VimEnter * call LambdaHandler()
で登録して一通り初期化処理がおわったらLambdaHandler()が呼び出されるように指定しています。
vimの$VIMRUNTIMEディレクトリのコピー
バイナリと上記vimrcでも一応vimとしては動くのですが、標準で付属してくるランタイムも同梱しなくては気持ちよくVim scriptでハンドラファイルが書けません。
VIM_VIMRUNTIME=`vim -e -T dumb --cmd 'exe "set t_cm=\<C-M>"|echo $VIMRUNTIME|quit' | tr -d '\015' `
VIM_RUNTIMEPATH=`vim -e -T dumb --cmd 'exe "set t_cm=\<C-M>"|echo &runtimepath|quit' | tr -d '\015' `
のようにして、インストールされたvimの$VIMRUNTIME
及びruntimepath
を取得します。
runtimepath
のディレクトリを一つ一つホストのディレクトリにコピーしていきます。
このとき、コピーしたディレクトリ一つ一つをruntimepathに追加するようなコードをvimrcに追記していきます。そして、もしコピーしたディレクトリが$VIMRUNTIME
に指定されているものだったら、そのコピー先を$VIMRUNTIME
に指定するようなコードも合わせて追記していきます。
ちょっとした苦労
runtimepath
はコンマで区切られたディレクトリが入っていますが、<runtime_dir>,<runtime_dir>/after
のように親子関係になったディレクトリも入っていますので、順番に単純にコピーしてきてしまうと重複してコピーされてしまいますので、処理しながら他の<runtime_dir>
をコピーしたタイミングで一緒にコピーされることになるものは無視する必要があります。そのあたりをなるべくシェルスクリプトだけでやりたかったので、ナイーブに正規表現で判定していますが、もう少し良い方法がありそうです..
ユーザーもプラグインを同梱できるように
これで、基本的なVIMRUNTIMEに入っているものは扱えるレイヤーが出来上がりますが、これだけではレイヤーを使う側のユーザーが独自で追加したいプラグイン等を追加することができません。
なので、最後に決め打ちでハンドラファイルが入っているのと同じディレクトリ内のvim/bundle
というディレクトリもruntimepathに含めるようにしてやります。
execute 'set rtp+=' . expand($LAMBDA_TASK_ROOT . "/vim/bundle")
このようにしておくと、レイヤーを使う側でvim/bundle内にautoload等のフォルダを置いておくと通常のvimと同様にautoloadの対象に含まれるようにできます。
これで、最終的にランタイム等の設定についても書かれたvimrcファイルが無事できあがります。
それらをまとめてzipファイルにして、レイヤーとしては完成です。
利用例(TOHtml)
たとえばハンドラー関数としてhandler.Method
を指定しておいて
以下のように、ユーザーからきたイベントのcontent
の内容をバッファに展開して、
指定されたカラースキームとファイルタイプを適用した状態でTOHtmlした結果のバッファを読み込んでやって
結果として返す関数を定義しておきます。
function! Method(event, context)
let content = a:event.content
enew!
put =content
1delete _
execute 'syntax on'
execute 'set ft='.a:event.filetype
execute 'colorscheme '.a:event.colorscheme
runtime syntax/2html.vim
execute bufwinnr(bufnr('Untitled.xhtml'))'.wincmd w'
let line_ending = "\n"
let text = join(getline(1, '$'), line_ending).line_ending
q!
return text
endfunction
すると、たとえばイベントとして以下のようなものを送ってやると
{
"filetype": "c",
"colorscheme": "blue",
"content": "#include <stdio.h>\n\nint main(int argc, char **argv) {\n\tprintf(\"Hello, world\\n\");\n\treturn 0;\n}"
}
このように、Lambdaから無事TOHtmlした結果が帰ってくるようになります:
たとえばここでvim/bundle/colors
内にお好みのカラースキームを同梱しておけば、標準でないカラースキームを適用した結果を得ることもできるようになると思います。
まとめ
Vim scriptでAWS Lambdaも書ける!