早くエンジニアになりたい

masatany's memorandum

AWS Lambda で Laravel を動かしてみた

会社のブログにも書きましたが、個人ブログにも書いておきます。

はじめに

AWS Lambda の Custom Runtime が来て、PHPが動くようになったので、
せっかくなら最近よく触っている Laravel を動かしてみようと思い立ちました。

aws.amazon.com

Lambda Layer を作る

まずは、こちらのリポジトリを参考に、Laravelが動作する Lambda Layer を作成していきます。

github.com

Laravel の 動作に必要な extension の追加

上のリポジトリのままでは、Laravel を動作させるための拡張がいくつか不足しているので、build.sh を書き換えて追加の拡張をLayerに追加します。

#!/bin/bash

yum install -y php71-cli php71-mbstring php71-mysqlnd php71-opcache php71-pdo php71-pgsql zip

mkdir /tmp/layer
cd /tmp/layer
cp /opt/layer/bootstrap .
cp /opt/layer/php.ini .

mkdir bin
cp /usr/bin/php bin/

mkdir lib
for lib in libncurses.so.5 libtinfo.so.5 libpcre.so.0; do
  cp "/lib64/${lib}" lib/
done

cp /usr/lib64/libedit.so.0 lib/

mkdir php-7.1.d/
cp -a /etc/php-7.1.d/* php-7.1.d/
cp -a /etc/php-7.1.ini php-7.1.d/php.ini
cp -a /usr/lib64/php lib/

zip -r /opt/layer/php71.zip .

bootstrap の修正

起動時に実行される bootstrap の下記の部分を、

exec("PHP_INI_SCAN_DIR=/opt/etc/php-7.1.d/:/var/task/php-7.1.d/ php -S localhost:8000 -c /var/task/php.ini -d extension_dir=/opt/lib/php/7.1/modules '$HANDLER'");

各種 ini ファイルが読み込まれるように、PHP_INI_SCAN_DIR を変更しておきます。

exec("PHP_INI_SCAN_DIR=/opt/etc/php-7.1.d/:/var/task/php-7.1.d/:/opt/php-7.1.d/ php -S localhost:8000 -c /var/task/php-7.1.d/php.ini -d extension_dir=/opt/lib/php/7.1/modules/ '$HANDLER'");

修正が済んだら、 make コマンドを実行することで、Layer の動作に必要な zip ファイルが出来上がります。

Lambda Layer の 公開

zip ファイルを、Lambda Layer に登録します。
upload.sh や publish.sh に記載されたバケット名やレイヤー名は自身の環境に合わせて適宜変更してください。 変更が正しく行われていれば、 make upload, make publish で Layer が登録されます。

注意点として、awscli が最新でないと、 aws lambda コマンドに Layer 周りのコマンドが存在しないためデプロイが失敗します。
必ず実行前に、awscli を最新のものにしておきましょう。

SAM テンプレートを作成する

サンプルの SAM テンプレートを下記のように編集します。

AWSTemplateFormatVersion: 2010-09-09
Description: My PHP Application
Transform: AWS::Serverless-2016-10-31
Resources:
  phpserver:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${AWS::StackName}-Laravel
      Description: Laravel on Lambda
      CodeUri: src/laravel
      Runtime: provided
      Handler: server.php
      MemorySize: 1028
      Timeout: 30
      Tracing: Active
      Layers:
        - !Sub arn:aws:lambda:${AWS::Region}:<アカウントID>:layer:<レイヤー名>:<レイヤーバージョン>
      Events:
        api:
          Type: Api
          Properties:
            Path: /{proxy+}
            Method: ANY
      Environment:
        Variables:
          TZ: Asia/Tokyo

Layers には先ほど作成した Layer の ARN を設定してください。

Lambda 関数を作る

Laravel プロジェクトの作成

template.yml の設置してあるディレクトリ配下に、src ディレクトリを作成し、Laravel プロジェクトを作成します。
composer はインストール済みの想定です。

mkdir src
cd src

php composer.phar create-project --prefer-dist laravel/laravel laravel "5.7.*"

各種キャッシュを書き込めるようにする

基本的に Lambda のファイルシステムは読み込み専用のため、キャッシュの書き込み等が実行できず、このままでは Laravel が動作しません。
キャッシュ用のリソースを用意するのは面倒だったので、今回は全てを Lambda 上で唯一書き込みが許可されている /tmp に書き込むようにします。

bootstrap/app.php

return $app;

の前に、下記の行を追加して storage のルートディレクトリを /tmp に上書きします。

$app->useStoragePath('/tmp');

また、 config/cache.phpconfig/view.php で存在しないディレクトリを指定しているとエラーが起きるので、それぞれ /tmp 直下に書き込むように変更します。

// config/cache.php

'file' => [
    'driver' => 'file',
    'path' => storage_path(''),
],
// config/view.php

'compiled' => env(
    'VIEW_COMPILED_PATH',
    realpath(storage_path(''))
),

セッションストレージを DynamoDBに逃がす

手前味噌ですが、今回はこちらの記事のように、DynamoDBをセッションストレージに変更しました。

k-masatany.hatenablog.com

セッションをファイルに残すこともできますが、その場合は、前述したように、 /tmp 配下に書き込むように設定してください。

これで最低限の設定が完了しました。

Lambda Function のデプロイ

sam コマンドでデプロイを実行します。

sam package \
    --template-file template.yml \
    --output-template-file serverless-laravel.yml \
    --s3-bucket <デプロイ用バケット名>

sam deploy \
    --template-file serverless-laravel.yml \
    --stack-name serverless-laravel \
    --capabilities CAPABILITY_IAM

※ DynamoDB の作成や、DynamoDB に Lambda からアクセスするための IAM の設定などは今回の記事の本筋とは外れるため、各自適切に設定してください。

動作確認

問題なくデプロイが完了したら、API Gateway 経由でアクセスしてみましょう。

https://<API Gateway のエンドポイント>Prod/

f:id:k_masatany:20181211140616p:plain

見慣れた画面が出てきました。

もし、{ "message": "Missing Authentication Token" } と表示される場合は、API Gateway のルートエンドポイントに対する GET リクエストを`先ほど作成した Lambda に proxy するように変更しましょう。

http://<API Gateway のエンドポイント>Prod/notfound

f:id:k_masatany:20181211141928p:plain

など、ルーティングを設定されていないエンドポイントにアクセスすると、きちんと 404 ページが表示されます。
ただし、svg/404.svg などがうまく処理できておらず、読み込めませんでした。
この辺は CloudFront 等をうまく使って静的ファイルを Lambda の外に逃がしてやる必要がありそうです。

今回作成したコードは、下記のリポジトリにテンプレートとして置いているので、興味のある方は使ってみてください。
なお、下記サンプルでは DynamoDB セッションストレージ化はしておらず、セッションはリクエスト毎に消えてしまいます。

github.com

最後に

今回は、Lambda の Custom Runtime を駆使して、Lambda 上で Laravel を動かしてみました。

まだベータ版ですが、Auroraサーバーレス のHTTPSエンドポイントも利用できるようになっていますので、
うまく実装すれば、Laravel on Lambda で使用するデータベースをAuroraにすることができる可能性があります。

docs.aws.amazon.com

セッションはDynamoに、ファイルはS3に、DBはAuroraにうまく逃がすことで、
真の意味で Laravel を Lambda で動作させることができると思っているので、今度挑戦してみたいと思っています。

Laravel のセッションストレージにDynamoDBを利用する

yoshitake_1201 がスルーしているので代わりに書いちゃいます。

この記事は、 Fusic Advent Calendar 2018 5日目の記事です。

はじめに

インフラのメイン担当として関わっている案件で、 ECS を使った構成で Laravel を動かすことになりました。

認証を含むWEBアプリケーションが複数のコンテナで動作するので、セッション管理が問題になってきます。
アプリケーション担当者と議論して、結果としてDynamoDBを使う運びになりました。

しかし、Laravel にはデフォルトで DynamoDB をセッションストレージとして使う機構は用意されていないので、今回はチーム内で実装することにしました。

やってみる

Laravel の公式ドキュメントを確認しながら進めます。
HTTPセッション 5.7 Laravel

ドライバの実装

カスタムセッションドライバでは、SessionHandlerInterfaceを実装してください。
このインターフェイスには実装する必要のある、シンプルなメソッドが数個含まれています。
MongoDBの実装をスタブしてみると、次のようになります。

<?php

namespace App\Extensions;

class MongoSessionHandler implements \SessionHandlerInterface
{
    public function open($savePath, $sessionName) {}
    public function close() {}
    public function read($sessionId) {}
    public function write($sessionId, $data) {}
    public function destroy($sessionId) {}
    public function gc($lifetime) {}
}

独自のセッションドライバを利用するためには、SessionHandlerInterface を実装する必要があるようです。
と言うことで、DynamoSessionHandler.php を作成します。

<?php
namespace App\Extensions;

class DynamoSessionHandler implements  implements \SessionHandlerInterface
{
    public function open($savePath, $sessionName) {}
    public function close() {}
    public function read($sessionId) {}
    public function write($sessionId, $data) {}
    public function destroy($sessionId) {}
    public function gc($lifetime) {}
}

DynamoDB SessionHandler のドキュメントを見ながら、実装を追加

Class Aws\DynamoDb\SessionHandler | AWS SDK for PHP 3.x

DynamoDbClient と SessionHandler が必要そうです。

<?php

use Aws\DynamoDb\DynamoDbClient;
use Aws\DynamoDb\SessionHandler;

SessionHandler の初期化時にDynamoDbSessionHandler も初期化します。

<?php

class DynamoSessionHandler implements  implements \SessionHandlerInterface
{
    protected $client;
    protected $handler;

    public function __construct(DynamoDbClient $client, array $config)
    {
        $this->client = $client;
        $this->handler = \Aws\DynamoDb\SessionHandler::fromClient($client, $config);
    }

    ...

}

open(), read()など、実際に DynamoDB をセッションハンドラーとして利用するための実装は AWS SDK の SessionHandler が実装しているので、ドライバからSDKにそのまま横流しすればOKです。

<?php

class DynamoSessionHandler implements  implements \SessionHandlerInterface
{

    ...

    public function open($save_path, $session_id)
    {
        return $this->handler->open($save_path, $session_id);
    }

   ...
}

ドライバの登録

再度 Laravel の公式ドキュメントを確認します。

ドライバを実装したら、フレームワークへ登録する準備が整いました。
Laravelのセッションバックエンドへドライバを追加するには、Sessionファサードのextendメソッドを呼び出します。
サービスプロバイダのbootメソッドから、extendメソッドを呼び出してください。
既存のAppServiceProviderか真新しく作成し、呼び出してください。

<?php

namespace App\Providers;

use App\Extensions\MongoSessionHandler;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\ServiceProvider;

class SessionServiceProvider extends ServiceProvider
{
    /**
     * サービス起動処理の事前登録
     *
     * @return void
     */
    public function boot()
    {
        Session::extend('mongo', function ($app) {
            // SessionHandlerInterfaceの実装を返す…
            return new MongoSessionHandler;
        });
    }

    /**
     * コンテナへ結合を登録する
     *
     * @return void
     */
    public function register()
    {
        //
    }
}

セッションドライバを登録したら、config/session.php設定ファイルでmongoドライバが使用できます。

ドライバを Laravel に認識させる必要があるようですので、公式ドキュメントにしたがって登録します。

まずは、 DynamoSessionServiceProvider.php を作成します。

<?php

namespace App\Providers;

use App\Extensions\DynamoSessionHandler;
use Aws\DynamoDb\DynamoDbClient;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\ServiceProvider;

class DynamoSessionServiceProvider extends ServiceProvider
{
    const DEFAULT_REGION = 'ap-northeast-1';

    public function boot()
    {
        Session::extend('dynamo', function ($app) {
            $cfg = $app['config']->get('session');

            $dynamoDbClient = DynamoDbClient::factory([
                'region' => (isset($cfg['region']) ? $cfg['region'] : self::DEFAULT_REGION),
                "endpoint" => (isset($cfg['endpoint']) ? $cfg['endpoint'] : 'https://dynamodb.'.self::DEFAULT_REGION.'.amazonaws.com'),
                'version'  => 'latest',
            ]);

            $config = [
                'table_name'               => $cfg['table'],
                'hash_key'                 => 'id',
                'session_lifetime'         => 60 * $cfg['lifetime'],
                'consistent_read'          => true,
                'locking_strategy'         => null,
                'automatic_gc'             => true,
                'gc_batch_size'            => 25,
                'max_lock_wait_time'       => 10,
                'min_lock_retry_microtime' => 10000,
                'max_lock_retry_microtime' => 50000
            ];
            // SessionHandlerInterfaceの実装を返す…
            return new DynamoSessionHandler($dynamoDbClient, $config);
        });
    }

    // 今回は直接実装しているので register() は利用しない
    public function register() {}
}

DynamoDbClient を作成する時に、リージョンやエンドポイントを環境変数から指定できるようにしました。

これで、 Laravel アプリケーションで DynamoSessionHandler が使用できるようになりました。

セッションストレージとして使う

いよいよ、DynamoDB をセッションストレージとして使用する準備が整いました。

セッション管理に使用する DynamoDB は session-table としましょう。 ハッシュキーは、コード上で id としているので間違えないように作成します。

$ aws dynamodb create-table \
    --table-name session-table \
    --attribute-definitions '[{"AttributeName":"id","AttributeType": "S"}]' \
    --key-schema '[{"AttributeName":"id","KeyType": "HASH"}]' \
    --provisioned-throughput '{"ReadCapacityUnits": 5,"WriteCapacityUnits": 5}'

あとは、config/session.php に必要な情報を記載するだけです。

<?php
return [

    /*
    |--------------------------------------------------------------------------
    | Default Session Driver
    |--------------------------------------------------------------------------
    |
    | This option controls the default session "driver" that will be used on
    | requests. By default, we will use the lightweight native driver but
    | you may specify any of the other wonderful drivers provided here.
    |
    | Supported: "file", "cookie", "database", "apc",
    |            "memcached", "redis", "array"
    |
    */

    'driver' => 'dynamo',
    'region' => 'ap-northeast-1',
    'table' => 'session-table',

    ...

無事に Laravel で DynamoDB をセッションストレージとして使用することができました!! (画面上は何の変化もない)

最後に

github.com

(^_^;)

でも、エンドポイントの指定ができるように作ったから、こっちは dynamodb-local が使えるもんね(強がり

SNSトピックへのメッセージをGoランタイムのLambdaでSlack通知する

前置き

AWSのリソースに関するイベントをSlackに通知したい欲があり 少し前にLamdaのGo言語サポートが実装されたことを思い出したので Goランタイムで動くLambda関数を作成しました。

実装コード

package main

import (
    "bytes"
    "context"
    "fmt"
    "net/http"
    "os"

    "github.com/antonholmquist/jason"
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

type Slack struct {
    WebhookURL string
    Channel    string
    Name       string
    Emoji      string
    Message    string
}

func send(s Slack) {
    jsonStr := `{"channel":"` + s.Channel +
        `","username":"` + s.Name +
        `","icon_emoji":"` + s.Emoji +
        `","text":"` + s.Message + `"}`

    req, err := http.NewRequest(
        "POST",
        s.WebhookURL,
        bytes.NewBuffer([]byte(jsonStr)),
    )

    if err != nil {
        fmt.Print(err)
    }

    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        fmt.Print(err)
    }

    fmt.Print(resp)
    defer resp.Body.Close()
}

func createMessage(message string) string {
    json, err := jason.NewObjectFromBytes([]byte(message))
    if err != nil {
        panic(err)
    }

    text := "<!channel>\n```\n"
    for k, v := range json.Map() {
        s, sErr := v.String()
        if sErr == nil {
            text += fmt.Sprintf("%s\t:%s\n", k, s)
        } else {
            text += fmt.Sprintf("%s\t:%s\n", k, sErr)
        }
    }
    text += "```"

    return text
}

func slackNotice(ctx context.Context, snsEvent events.SNSEvent) {
    for _, record := range snsEvent.Records {
        snsRecord := record.SNS
        var s = Slack{os.Getenv("WEBHOOK_URL"),
            os.Getenv("SLACK_CHANNEL"),
            os.Getenv("SLACK_NAME"),
            os.Getenv("SLACK_EMOJI"),
            createMessage(snsRecord.Message)}

        send(s)
    }
}

func main() {
    lambda.Start(slackNotice)
}

Slackに通知するためのPost処理の部分は、

crossbridge-lab.hatenablog.com

こちらの記事を参考にさせていただきました。

SNSのイベント情報を取得する

今回の本命処理は、以下の部分です。 (本命と言うほどでもない)

func slackNotice(ctx context.Context, snsEvent events.SNSEvent) {
    for _, record := range snsEvent.Records {
        snsRecord := record.SNS
        var s = Slack{os.Getenv("WEBHOOK_URL"),
            os.Getenv("SLACK_CHANNEL"),
            os.Getenv("SLACK_NAME"),
            os.Getenv("SLACK_EMOJI"),
            createMessage(snsRecord.Message)}

        send(s)
    }
}

  今回は、SNSトピックに色々なイベントを集約して同一のSlackチャンネルに通知する方針です。 そのため、snsEvent events.SNSEventでトリガーとなったSNSのイベント情報を取得しています。

また、Slack通知関係の定数(WebhookURLなど)は、Lambda関数の環境変数os.Getenvで取ってくるようにしているため、別のアカウントでも流用できる形となっています。

まとめ

Go言語は初学者中の初学者なのですが、割と直感的に書けていい感じの言語だと思いました。 また、フォーマッティングまで言語がサポートしてくれるのはとても嬉しいですね。

もっと複雑な処理もサーバーレスに処理できるようにLambdaGoの知見を貯めて行きたいと思います。

TerraformでAutoScalingGroupを作るときのメモ

起動設定周りで少しハマった。

公式サイトにサンプルコードがあって、見れば一目瞭然なんだけど、
日本語の情報がヒットしないので自分用にメモしておく。

コード

# 起動設定
resource aws_launch_configuration lc {
  name_prefix     = "${var.common["app_name"]}-lc-${terraform.workspace}-"
  image_id        = "${var.launch_configuration["ami-id"]}"
  instance_type   = "${var.launch_configuration["${terraform.workspace}.instance_type"]}"
  security_groups = ["${var.security_group["ec2_web_id"]}"]
  key_name        = "${var.launch_configuration["key_name"]}-${terraform.workspace}"
  user_data       = "${file("${path.module}/userdata.sh")}"

  lifecycle {
    create_before_destroy = true
  }

  depends_on = [
    "aws_launch_configuration.lc",
  ]

}

# グループ
resource aws_autoscaling_group asg {
  name = "${var.common["app_name"]}-asg-${terraform.workspace}"

  availability_zones = [
    "${var.autoscaling_group["availability_zone1"]}",
    "${var.autoscaling_group["availability_zone2"]}",
  ]

  vpc_zone_identifier = [
    "${var.public_subnet_ids[0]}",
    "${var.public_subnet_ids[1]}",
  ]

  launch_configuration = "${aws_launch_configuration.lc.name}"
  min_size             = "${var.autoscaling_group["${terraform.workspace}.min_size"]}"
  max_size             = "${var.autoscaling_group["${terraform.workspace}.max_size"]}"
  target_group_arns    = ["${var.lb_target_group}"]

  lifecycle {
    create_before_destroy = true
  }

  depends_on = [
    "aws_launch_configuration.lc",
  ]
}

大事なところは

name_prefix = "${var.common["app_name"]}-lc-${terraform.workspace}-"

lifecycle { create_before_destroy = true }

name_prefixを指定すると、prefixの後ろに自動で日付を基にした文字列をつけてくれるので、
「launch config の名前が重複している」エラーが出なくなる。

create_before_destroyで、先に更新したlaunch configを作成してAutoScalingGroupにアタッチしてくれるので、
「launch configが消せない」エラーが出なくなる。

Nuxt.jsで作成したページにログイン機構を作る

クリスマスなんて関係ない!!

この記事はFusic Advent Calendar 2017 25日目の記事です。

Fusicでは二ヶ月に一回くらいの頻度で「エンジニア開発合宿」と称して丸1日泊まり込みで自分の興味のある技術やガジェットに関する開発を行うイベントがあります(2017年12月時点)。

自分は、フロントエンドに苦手意識があるので(というか苦手)、javascriptフレームワークを使ってアプリを作成したいと思い、
今回はNuxt.jsでログイン認証ができるところまでを実装しました。

プロジェクトの作成

1. vue-cliをインストール

Vue.jsを使う環境をいい感じに構築してくれるコマンドラインインタフェースをインストールします。

$ npm install -g vue-cli

2. vue init

プロジェクトの初期化を行います。

$ vue init nuxt-community/starter-template hello_nuxt

? Project name hello_nuxt
? Project description Nuxt.js project
? Author k-masatany <masatani@fusic.co.jp>

   vue-cli · Generated "hello_nuxt".

   To get started:

     cd hello_nuxt
     npm install # Or yarn
     npm run dev

作成されたプロジェクトに必要なnodeモジュールをインストールします。

$ cd hello_nuxt/
$ yarn

Hello, Nuxt.js

$ yarn dev

を実行して、 http://localhost:3000 にアクセスします。 下記のようなページが表示されるはずです。 f:id:k_masatany:20171224224022p:plain

認証機構のベースを作成する

基本的な部分は公式ページを参考にして、認証機構を構築していきます。 (細かい説明は公式ページに書いてあるので、作業内容を箇条書きしています。)

1. 依存パッケージをインストール

$ yarn add express express-session body-parser whatwg-fetch

2. server.jsファイルを作成

プロジェクトのルートディレクトリにserver.jsを作成します。

const { Nuxt, Builder } = require('nuxt')
const bodyParser = require('body-parser')
const session = require('express-session')
const app = require('express')()

// req.body へアクセスするために body-parser を使う
app.use(bodyParser.json())

// req.session を作成します
app.use(session({
  secret: 'super-secret-key',
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 60000 }
}))

// POST /api/login してログインし、認証されたユーザーを req.session.authUser に追加
app.post('/api/login', function (req, res) {
  if (req.body.username === 'k-masatany' && req.body.password === 'demo') {
    req.session.authUser = { username: 'k-masatany' }
    return res.json({ username: 'k-masatany' })
  }
  res.status(401).json({ error: 'Bad credentials' })
})

// POST /api/logout してログアウトし、ログアウトしたユーザーを req.session から削除
app.post('/api/logout', function (req, res) {
  delete req.session.authUser
  res.json({ ok: true })
})

// オプションとともに Nuxt.js をインスタンス化
const isProd = process.env.NODE_ENV === 'production'
const nuxt = new Nuxt({ dev: !isProd })
// プロダクション環境ではビルドしない
if (!isProd) {
  const builder = new Builder(nuxt)
  builder.build()
}
app.use(nuxt.render)
app.listen(3000)
console.log('Server is listening on http://localhost:3000')

このサンプルコードではk-masatany/demoでしかログインできません。

3. package.jsonを更新

先ほど作成したserver.jsを読み込むようにします。

{
  "name": "hello_nuxt",
  "version": "1.0.0",
  "description": "Nuxt.js project",
  "author": "k-masatany <masatani@fusic.co.jp>",
  "private": true,
  "scripts": {
    "dev": "node server.js",  // ここと
    "build": "nuxt build",  // ここと
    "start": "cross-env NODE_ENV=production node server.js",  // ここ
    "generate": "nuxt generate",
    "lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
    "precommit": "npm run lint"
  },
  "dependencies": {
    "body-parser": "^1.18.2",
    "express": "^4.16.2",
    "express-session": "^1.15.6",
    "nuxt": "^1.0.0-rc11",
    "whatwg-fetch": "^2.0.3"
  },
  "devDependencies": {
    "babel-eslint": "^7.2.3",
    "eslint": "^4.3.0",
    "eslint-config-standard": "^10.2.1",
    "eslint-loader": "^1.9.0",
    "eslint-plugin-html": "^3.1.1",
    "eslint-plugin-import": "^2.7.0",
    "eslint-plugin-node": "^5.1.1",
    "eslint-plugin-promise": "^3.5.0",
    "eslint-plugin-standard": "^3.0.1"
  }
}

その後、

$ yarn add cross-env
$ yarn dev

を実行してデバッグ環境を再起動します。

4. ストアを作成する

ユーザーの情報を保持するためのstore/user.jsを作成します。

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

// window.fetch() のためのポリフィル
require('whatwg-fetch')

const store = () => new Vuex.Store({

  state: {
    authUser: null
  },

  mutations: {
    SET_USER: function (state, user) {
      state.authUser = user
    }
  },

  actions: {
    nuxtServerInit ({
      commit
    }, {
      req
    }) {
      if (req.session && req.session.authUser) {
        commit('SET_USER', req.session.authUser)
      }
    },
    login ({
      commit
    }, {
      username,
      password
    }) {
      return fetch('/api/login', {
        // クライアントのクッキーをサーバーに送信
        credentials: 'same-origin',
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          username,
          password
        })
      })
        .then((res) => {
          if (res.status === 401) {
            throw new Error('Bad credentials')
          } else {
            return res.json()
          }
        })
        .then((authUser) => {
          commit('SET_USER', authUser)
        })
    },
    logout ({
      commit
    }) {
      return fetch('/api/logout', {
        // クライアントのクッキーをサーバーに送信
        credentials: 'same-origin',
        method: 'POST'
      })
        .then(() => {
          commit('SET_USER', null)
        })
    }
  }
})

export default store

5. 認証が必要なページを作成

/helloルートを作成するため、pages/hello.vueを作成します。 今回はindex.vueをコピーして、文面だけ変えています。

<template>
  <!-- 中身は自由に作成 -->
  <section class="container">
    <div>
      <h2 class="subtitle">
        Hello!!
      </h2>
    </div>
  </section>
</template>

<script>
export default {
  // データをこのコンポーネントにセットする必要がないため fetch() を使う
  fetch ({ store, redirect }) {
    if (!store.state.authUser) {
      return redirect('/auth')
    }
  }
}

デフォルトのindex.vueに手を加えて、/helloへのリンクを作成します。

<template>
  <section class="container">
    <div>
      <logo/>
      <h1 class="title">
        hello_nuxt
      </h1>
      <h2 class="subtitle">
        Nuxt.js project
      </h2>
      <div class="links">
        <a href="/hello" class="button--green"> go to hello page</a>  // ここ
      </div>
    </div>
  </section>
</template>

f:id:k_masatany:20171224233331p:plain

今はまだ認証がおこなわれていないので、ボタンをクリックすると/authにリダイレクトされます。 /authはまだ作成されていないので、404になります。 f:id:k_masatany:20171224233950p:plain

6. ログインページを作成

認証用の/authページを作成するために、pages/auth.vueを作成します。 CSSなどは適当に当ててください。

<template>
  <section class="container">
    <div>
      <h2 class="subtitle">
        Login
      </h2>
      <form v-if="!$store.state.authUser" @submit.prevent="login">
        <p class="error" v-if="formError">{{ formError }}</p>
        <div>
          <input type="text" class="form-control" v-model="formUsername" name="username" placeholder="Username" />
          <input type="password" class="form-control" v-model="formPassword" name="password"  placeholder="Password" />
          <button type="submit" class="button--green block">Login</button>
        </div>
      </form>
      <div v-else>
        <h2>Hello {{ $store.state.authUser.username }}!</h2>
        <div class="links">
          <a href="/hello" class="button--green">go to hello page</a>
          <button class="button--grey" @click="logout">Logout</button>
        </div>
      </div>
    </div>
  </section>
</template>

<script>
export default {
  data() {
    return {
      formError: null,
      formUsername: '',
      formPassword: ''
    }
  },
  methods: {
    async login() {
      try {
        await this.$store.dispatch('login', {
          username: this.formUsername,
          password: this.formPassword
        })
        this.formUsername = ''
        this.formPassword = ''
        this.formError = null
      } catch (e) {
        this.formError = e.message
      }
    },
    async logout() {
      try {
        await this.$store.dispatch('logout')
      } catch (e) {
        this.formError = e.message
      }
    }
  }
}
</script>

f:id:k_masatany:20171225001452p:plain

ログインしていない状態では、フォームが表示され、認証が通ったらユーザー名と/helloへのリンクが表示されます。

ログインしてみる

k-masatany/demoでログインできるので、入力します。 f:id:k_masatany:20171225002115p:plain

きちんとk-masatanyと表示されています。
それでは、改めて/helloへ移動してみます。ログイン後のボタンをクリックしてみましょう。 f:id:k_masatany:20171225002204p:plain ※画像はいらすとや様よりお借りしました。

無事に表示されました。(hello.vueのコードは書き換えました) この状態であれば、/に戻って/helloへのリンクをクリックしても/authへ飛ばされることはありません。

終わりに

今回は公式ページを参考にして、Nuxt.jsにログイン機構を作成しました。
今後は、AWS Cognitoなどを使ったログインの仕組みをつけたいと思います。

nc(netcat)コマンドでHTTPサーバーの気持ちを知る

この記事はFusic Advent Calendar 2017 12日目の記事です。

WEBエンジニアとしていつもお世話になっているApacheやNginxと言った「HTTPサーバーの気持ち」を知ろうと思い立ったので、ncコマンドを使ってHTTPリクエストを受けてHTTPレスポンスを返す一連の流れを体験してみました。

まがいなりにもネットワークスペシャリストなのでネットワーク関連の記事を書こうと思っていましたが、良いネタが思いつかなかったので、今日はレイヤー7で遊ぼうと思います。

ncコマンドで簡易サーバーを立てる

何はともあれ、HTTPを受けるためのサーバーが必要です。 8000番ポートで待ち受けるTCPサーバーをncコマンドで構築します。

while : ; do  nc -l 127.0.0.1 8000; done

これでオレオレHTTPサーバ(文字通り)の構築は終わりです。簡単ですね。 もちろんこのサーバーは、同時に1つのリクエストしか処理できません。

ncコマンドはコネクションがクローズすると、プロセスが終了してしまいます。 一々コマンドを再実行してもいいですが、面倒臭いのでwhileで永続化しています。

HTTPリクエストを受けてみる

おもむろにブラウザ(Chromeがおすすめ)を立ち上げて、http://localhost:8000にアクセスしてみます。 おそらく、下記のようなHTTPリクエストが飛んでくるはずです。

$ while : ; do  nc -l 127.0.0.1 8000; done
GET / HTTP/1.1
Host: localhost:8000
Connection: keep-alive
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Cookie: username-localhost-50080="2|1:0|10:1511168650|24:username-localhost-50080|44:Nzc3OWJmNjE0NTI5NDEzZDk2Y2I2MzU5ZmEwMmE1MDQ=|4279df9a2de706e91212b77f6ef85216f0b7f2f9fb8211e6430c53b5f283b952"

f:id:k_masatany:20171208233945p:plain

ブラウザにはまだ何も表示されません。 レスポンスを返していないのだから当然ですね。

HTTPレスポンスを返す

いつまでもブラウザを待たせているのもかわいそうなので、そろそろレスポンスを返してあげます。 コンソールに、下記の内容のレスポンスヘッダを返します。

HTTP/1.1 200 OK
Server: k-masatany 28.5 (nightly)
Content-Type: text/html

<!DOCTYPE html>
<html>
<title>nc server</title>
</head>
<body>

ここまで入力すると、Chromeを使っていれば、開いているページが真っ白になり、ページタイトルが「nc server」になっているはずです。 Safariだと、Ctrl+DでEOFを送信するまでレンダリングは行われませんでした。 f:id:k_masatany:20171208234624p:plain

bodyの内容も送る

真っ白なページでは面白くないのでbodyの内容を送ってみましょう。 先ほどの<body>に続いて、下記の内容を書いて行きます。

<div>Hello, HTTP World!</div>
<h1>H1 Message</h1>
<h2 style="color: red">Red Color Message<h2>
<h3>hogehoge</h3>

Chromeでは、タグを閉じるたび、レンダリングが行われます。 f:id:k_masatany:20171208235246p:plain

404 NotFoundを返してみる

http://localhost:8000/notfoundにアクセスしてみます。 ここにはコンテンツがないので、404を返してみます。 (オレオレHTTPサーバにはそもそもコンテンツがないので全部404なのですが・・・)

HTTP/1.1 404 Not Found
Server: k-masatany 28.5 (nightly)
Content-Type: text/html

<!DOCTYPE html>
<html>
<title>nc server</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css" integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb" crossorigin="anonymous">
</head>
<body>
<h1 class="display-1">404 Not Found</h1>
<p class="h2">ご指定のページは見つかりませんでした<p>

f:id:k_masatany:20171209002214p:plain おっと、文字化けしてますね(しれっとBootstrap適用)。

ページをリロードして、metaタグを追加したレスポンスbodyを再送信します。

HTTP/1.1 404 Not Found
Server: k-masatany 28.5 (nightly)
Content-Type: text/html

<!DOCTYPE html>
<html>
<meta charset="utf-8">
<title>nc server</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css" integrity="sha384-PsH8R72JQ3SOdhVi3uxftmaW6Vc51MKb0q5P2rRUpPvrszuE4W1povHYgTpBfshb" crossorigin="anonymous">
</head>
<body>
<h1 class="display-1">404 Not Found</h1>
<p class="h2">ご指定のページは見つかりませんでした<p>

f:id:k_masatany:20171209002222p:plain 日本語も無事表示されました。

404ステータスコードを返しても、bodyにコンテンツを入れていると、描画されてしまうので、 レスポンスボディを消してみます。

HTTP/1.1 404 Not Found
Server: k-masatany 28.5 (nightly)
Content-Type: text/html
^D # Ctrl+D

ChromeのHTTP ERROR 404ページになりました。 きちんとステータスコードが影響しています。f:id:k_masatany:20171209002839p:plain

HTTPで遊んでみて

色々HTTPで遊んでみましたが、各種HTTPヘッダに対する応答をはじめ、オレオレおHTTPサーバーでは様々な機能が使えません。

こんな面倒なことをいつも一瞬でやってくれているHTTPサーバーに感謝を感じる遊びでした。

最後に

忙しくてレスポンスが返せない時は、きちんと503エラーを返しましょう。

f:id:k_masatany:20171209003745p:plain

HTTP/1.1 503 Service Unavailable
Server: k-masatany 28.5 (nightly)
Content-Type: text/html
^D # Ctrl+D

YAPC::Fukuoka 2017に行ってきました

ブログを書くまでがYAPCです。

というわけで、「弊社の参加者はブログを書かないのかしら」と社内Slackで実行委員長さんに突っつかれたので、久々の更新です。
そもそも、YAPCに限らず、こういったイベントに参加すること自体が初めて(だと思う)なので、期待半分、不安半分でした。

拝見したセッション一覧(講演順)

  • レガシーPerlと「今」を組み合わせ、開発を継続し続ける方法(山下 和彦さん)
  • 稼働中の Web サービスの Perl 処理系バージョンアップをしていく話(astjさん)
  • ウェブセキュリティの最近の話題早分かり(徳丸 浩さん)
  • コンテナを「守る」仕組みから、中身を理解しよう!(近藤 宇智朗さん)
  • システム障害をめぐる冒険 (タケタニヒロトさん)
  • Web application good error messages and bad error messages(moznionさん)
  • cpm(鍛治 匠一さん)
  • 本編スペシャルセッション: 福岡のIT企業、開発現場の未来
  • Inside Evalpark - the evolution of sandboxing(Dan Kogaiさん)
  • 巨大Perlプロジェクトに、Dockerが出会った(acidlemonさん)
  • Lightning Talk
  • Sponsor Session
  • 福岡からニューヨークへ転勤になったエンジニアの話(山本 竜三さん)

印象に残ってるセッション

コンテナを「守る」仕組みから、中身を理解しよう!

このセッションが見たくて参加したこともあり、とても勉強になりました。
コンテナと呼ばれる技術の基本的な考え方が理解でき、好奇心から、帰ってすぐにコンテナ風プロセスを実装して遊びました。

ウェブセキュリティの最近の話題早分かり

「時間がないのでサクッと攻撃しちゃいますね」というパワーワードが今でも頭から離れない。そんなセッション。
登壇用に用意されたサイトだとしても、徳丸さんがいとも簡単に脆弱性をついている様子を見て、「今までよりももっとセキュリティに意識を向けないといけない」と強く感じました。

割と嬉しかったこと

帰宅後、@udzuraさんのセッションで出てきたコンテナ(風)プロセスを、スライドを見ながらC言語で実装してみました。
(とりあえずchrootとか試すだけのクソコードなのでコードは割愛します)
思っていたよりずっと簡単に、コンテナみたいなプロセスができてしまいました。
↑の話を呟いたら、@udzuraさん本人がいいねしてくれたのが地味に嬉しかったです。

まとめ

前は割と「イベントってスライドも大体ネットに上がるし、行くほどでもないかな」と思って参加はあまりしていませんでした。
今回も、社内の参加予定の方が仕事の都合で行けなくなったため、チケットを譲っていただき参加しています。

ただ、今回の参加をきっかけに、少し考えを改めようと思いました。
行かないと得られない情報もありますし、何よりあの空気感は向上心を刺激してくれるので、今後も定期的にイベントには参加したいと思いました。

運営の皆様、登壇者の皆様、ありがとうございました。