AWS Lambda で Dagger 2 を使う

AWS Lambda + API Gateway で Serverless Application を構築する際に Dagger 2 を使うサンプルです。

AWS Lambda 関数を使用する際のベストプラクティス - AWS Lambda において、Java では Dagger の利用が推奨されています。

依存関係の複雑さを最小限に抑えます。フレームワークを単純化して、実行コンテキスト起動時のロードの高速化を優先します。たとえば、Spring Framework などの複雑なフレームワークよりも、Dagger や Guice などの単純な Java 依存関係インジェクション (IoC) フレームワークを使用します。

アプリケーション構成

  • ApiHandler: Lambda のエントリポイント
  • Controller: ServerlessInput / ServerlessOutput を操作する
  • Service: 実際の処理を行う

ライブラリ

  • Dagger 2.14

Dagger 系クラスの説明

  • アプリケーション全体でインスタンスを共有するための AppComponent, AppModule, (@Singletone annotation)
  • HTTPリクエスト単位でインスタンスを生成・共有するための RequestComponent ,RequestModule, @Request annotation
  • RequestComponentAppComponent の Subcomponent とし、リクエストを処理するタイミングで生成する (appComponent.newRequest())
  • RequestComponent の生成時には、そのリクエストに紐づいた情報をRequestModule を使って各オブジェクトに渡すようにする (RequestModule のコンストラクタに設定する)
    • (TODO Binding Instances が使えるかもしれない。ただし型ではなく名前ベースになる)

Dagger 関連ソースコード

di/AppComponent.java

@Singleton
@Component(modules = {AppModule.class})
public interface AppComponent {
  RequestComponent newRequest(RequestModule requestModule);
}

di/AppModule.java

@Module
public class AppModule {
  @Provides
  @Singleton
  public static AuthModel provideAuthModel() {
    return new AuthModel();
  }
}

di/RequestScope.java

@Scope
@Retention(RetentionPolicy.RUNTIME)
@interface RequestScope {}

di/RequestComponent.java

@RequestScope
@Subcomponent(modules = {RequestModule.class})
public interface RequestComponent {
  SystemController newSystemController();

  UserController newUserController();
}

di/RequestModule.java

@Module
public class RequestModule {
  private final String serverlessInput;
  private final Object context;

  public RequestModule(String serverlessInput, Object context) {
    this.serverlessInput = serverlessInput;
    this.context = context;
  }

  @Provides
  public SystemService provideSystemService() {
    return new SystemService();
  }

  @Provides
  @RequestScope
  public UserInfo provideUserInfo() {
    return new UserInfo();
  }

  @Provides
  @Named("ServerlessInput")
  public String provideServerlessInput() {
    return serverlessInput;
  }

  @Provides
  @Named("Context")
  public Object provideContext() {
    return context;
  }
}

アプリケーションソースコード

build.gradle

plugins {
    id 'java-library'
    id "net.ltgt.apt" version "0.10"
    id 'eclipse'
}

repositories {
    jcenter()
}

dependencies {
    api 'org.apache.commons:commons-math3:3.6.1'

    testCompile 'org.jmockit:jmockit:1.42'
    testCompile 'org.junit.jupiter:junit-jupiter-api:5.2.0'
    implementation 'com.google.guava:guava:23.0'
    implementation 'org.apache.httpcomponents:httpclient:4.5.6'

    testRuntime 'org.junit.jupiter:junit-jupiter-engine:5.2.0'
    testRuntime "org.junit.platform:junit-platform-launcher:1.2.0"
    compile 'com.google.dagger:dagger:2.17'
    apt 'com.google.dagger:dagger-compiler:2.17'
}

app/ApiHandler.java

public class ApiHandler {
  private final AppComponent appComponent;

  public static void main(String[] args) {
    ApiHandler handler = new ApiHandler();
    String result = handler.handleRequest("version", "context");
    System.out.println(result);
    String result2 = handler.handleRequest("version", "context");
    System.out.println(result2);
    String result3 = handler.handleRequest("user/aaa", "context");
    System.out.println(result3);
  }

  public ApiHandler() {
    System.out.println("Create handler");
    appComponent = DaggerAppComponent.create();
  }

  public String handleRequest(String input, String context) {
    System.out.println("---start ApiHandler#handleRequest()---");
    RequestModule requestModule = new RequestModule(input, context);
    RequestComponent requestComponent = appComponent.newRequest(requestModule);
    if (input.startsWith("user")) {
      UserController uc = requestComponent.newUserController();
      return uc.getUser();

app/UserController.java

public class UserController {
  private final UserService userService;
  private final UserInfo userInfo;
  private final String serverlessInput;
  private final Object context;

  @Inject
  public UserController(
      UserService userService,
      UserInfo userInfo,
      @Named("ServerlessInput") String serverlessInput,
      @Named("Context") Object context) {
    System.out.println("Create SystemController");
    this.userService = userService;
    this.userInfo = userInfo;
    this.serverlessInput = serverlessInput;
    this.context = context;
  }

PukiWikiにTwitterアカウントでログイン

PukiWiki 1.5.1において、Twitterアカウントでログインした人にだけ編集を許可する設定のメモです。

まとめ

OpauthはTwitter以外のSNSにも対応していますが、ここではTwitterに限定した設定を行います。

確認環境

Twitterアプリ連携設定

Twitter連携アプリとして対象PukiWikiサイトを登録

https://apps.twitter.com/ へ行き Create New App ボタンで新規アプリを作成・登録します。

  • Name: PukiWikiサイトの名称
  • Description: サイトの説明
  • Website: サイトのURL
  • Callback URL: (空のまま)

Permission設定

Permissions ページ で Access - Read only に設定します。

連携に必要な情報を確認

Keys and Access Tokens ページ で “Consumer Key” と “Consumer Secret” を確認します。この値を後で opauth_config.php へ設定することになります。

PukiWiki設置サーバーでOpauth設定

opauthをcomposerでインストールします。

opauth と opauth/twitter (プラグイン) をインストール

PukiWikiindex.phpがある場所と同じディレクトリに以下のような composer.json を用意します。

{
  "require":{
    "opauth/opauth": "*",
    "opauth/twitter": "*"
  }
}

composerでopauthをインストールします。

$ composer install

twitterlogin.php を配置

以下のtwitterlogin.phpをそのまま、PukiWikiindex.phpと同じ場所に保存します。

<?php
$root_dir = __DIR__;
require $root_dir . '/opauth_config.php';

$url_after_login = filter_input(INPUT_GET, 'url_after_login');
$opauth_step2 = filter_input(INPUT_GET, '_opauth_step2');
$opauth_step3 = filter_input(INPUT_GET, '_opauth_step3');
$request_uri = filter_input(INPUT_SERVER, 'REQUEST_URI');

if ($opauth_step3 === '3') {
  // Step 3
  session_start();

  if ($url_after_login) {
    if (substr($url_after_login, 0, 1) === '/') {
      header('HTTP/1.0 302 Found');
      header("Location: " . $url_after_login);
    } elseif (substr($url_after_login, 0, strlen($redirect_url_secure_prefix))
        === $redirect_url_secure_prefix) {
      header('HTTP/1.0 302 Found');
      header("Location: " . $url_after_login);
    }
  }
  $b = isset($_SESSION['opauth']);
  if ($b) {
    $nickname = $_SESSION['opauth']['auth']['info']['nickname'];
    $_SESSION['authenticated_user'] = $nickname;
  }

?>
<html><body><pre>
Step 3

uid: <?php echo ($b ? htmlspecialchars($_SESSION['opauth']['auth']['uid']) : '') ?> .
nickname: <?php echo ($b ? htmlspecialchars($_SESSION['opauth']['auth']['info']['nickname']) : '') ?> .
name: <?php echo ($b ? htmlspecialchars($_SESSION['opauth']['auth']['info']['name']) : '') ?> .
provider: <?php echo ($b ? htmlspecialchars($_SESSION['opauth']['auth']['provider']) : '') ?> .

url_after_login: <?php echo htmlspecialchars($url_after_login) ?> .
redirect_url_secure_prefix: <?php echo htmlspecialchars($redirect_url_secure_prefix) ?> .
</pre></body></html>
<?php
    exit;
} elseif ($opauth_step2 === '2') {
  // Step 2
  require $root_dir . '/vendor/autoload.php';
  $m = [];
  if (preg_match('#/.+[\?&]_opauth_step2=2&/#', $request_uri, $m) === 1) {
    $config['path'] = $m[0];
    $config['callback_url'] = $callback_url_php
      . '?url_after_login=' . rawurlencode($url_after_login)
      . '&_opauth_step3=3';
    new Opauth($config);
  } else {
    echo "Error on step2";
  }
} else {
  // Step 1
  if (strpos($request_uri, '?') === false) {
    $opauth_path = $request_uri . '?' . '_opauth_step2=2&/';
  } else {
    $opauth_path = $request_uri . '&' . '_opauth_step2=2&/';
  }
  require $root_dir . '/vendor/autoload.php';
  $config['path'] = $opauth_path;
  $config['request_uri'] = $opauth_path  . 'twitter';
  new Opauth($config);
}

opauth_config.php の設定

<?php
$config = [
    'security_salt' => 'LDFmiilYf8Fyw5W1', // ★必ず変更する
    'Strategy' => [
        'Twitter' => [
            'key' => '<Your Consumer Key>', // ★設定する
            'secret' => '<Your Consumer Secret>', // ★設定する
        ],
    ]
];
// URL of twitterlogin.php
$callback_url_php = 'http://pukiwiki-twitter.example.com/wiki/twitterlogin.php';
$redirect_url_secure_prefix = 'http://pukiwiki-twitter.example.com/';

必ず 'security_salt' の値を変更します。 (参照: Opauth configuration · opauth/opauth Wiki · GitHub - security_salt

)

<Your Consumer Key>, <Your Consumer Secret> の部分にTwitterのApplication Management - Keys and Access Tokens で取得した値を設定します。

$callback_url_php には作成した twitterlogin.php に対応するURLを、$redirect_url_secure_prefix にはサイトのドメイン部分を設定します。 http/https部分も一致している必要があります。

Twitterログイン動作テスト

opauth_config.php$callback_url_php として設定したURL ( http://pukiwiki-twitter.example.com/wiki/twitterlogin.php ) にアクセスします。

Twitterのアプリ連携画面が表示されたら最初の段階は成功です。

f:id:umorigu:20170507055659p:plain

「連携アプリを認証」をクリックすると、次のような画面が表示されます。

f:id:umorigu:20170507055559p:plain

アプリ側でtwitterから認証情報が取れたことを確認します。

PukiWiki連携設定

PukiWiki 1.5.1 での連携設定です。

pukiwiki.ini.php の設定

pukiwiki.ini.php で外部認証の設定をします。

$scriptPukiWikiトップページを示すURLを設定します。

// Specify PukiWiki URL (default: auto)
//$script = 'http://example.com/pukiwiki/';
$script = 'https://pukiwiki-twitter.example.com/wiki/';

$auth_typeAUTH_TYPE_EXTERNAL$auth_external_login_url_base'./twitterlogin.php' を設定します。

// Authentication type
// AUTH_TYPE_NONE, AUTH_TYPE_FORM, AUTH_TYPE_BASIC, AUTH_TYPE_EXTERNAL, ...
$auth_type = AUTH_TYPE_EXTERNAL;
$auth_external_login_url_base = './twitterlogin.php';

$auth_provider_user_prefix_external には 'twitter' を設定します。

//$auth_provider_user_prefix_external = 'external:';
$auth_provider_user_prefix_external = 'twitter:';

PukiWiki連携動作テスト

PukiWiki にアクセスすると ヘッダに ログイン のリンクが表示されています。

f:id:umorigu:20170507060557p:plain

ログインをクリックして、Twitter認証画面に飛び、元の画面に戻ってくることが確認できればOKです。

ログイン後は「ログイン」のリンクが「ログアウト」に変わります。

f:id:umorigu:20170507060629p:plain

PukiWiki編集認証設定

全ページの「編集」操作にtwitterアカウントが必要な設定にします。

pukiwiki.ini.php を編集します。

$edit_auth を 1 に、$edit_auth_pages に認証な必要なページを設定します。

// Edit auth (0:Disable, 1:Enable)
$edit_auth = 1;

$edit_auth_pages = array(
        // Regex                   Username
        '#.*#'  => 'valid-user',
);

valid-user は特殊グループで、認証が通ったすべてのユーザーを表します。

PukiWiki認証設定の詳細は PukiWiki/Authentication - PukiWiki-official に記載があります。

PukiWiki編集認証動作テスト

ログアウト状態で、PukiWiki上で「編集」リンクをクリックしたとき、Twitter認証を経由してログイン状態に遷移することを確認します。

PukiWiki側でのログアウト

「ログアウト」リンクをクリックします。

Twitter側でのログアウト(アプリ連携解除)

Twitterメニューの 設定とプライバシー - アプリ連携 (https://twitter.com/settings/applications ) から、対象PukiWikiサイトの「許可を取り消す」を実行します。

ファイル配置まとめ

twitterlogin関連のファイルは、PukiWikiindex.phpと同じディレクトリに配置します。

+ pukiwiki_root/
  - wiki/
  - backup/
  - skin/
  - cache/
  - diff/
  - counter/
  - ...
  - index.php
  - pukiwiki.ini.php
  - vendor/ (composerにより追加)
  - composer.json (追加)
  - twitterlogin.php (追加)
  - opauth_config.php (追加)

リダイレクト関係図

f:id:umorigu:20170507055638p:plain

ChromeでエクスプローラからフォルダをD&Dする

WebページにフォルダをD&Dしたいと思って調べたのでメモを残しておく。


実現したいこと

  • エクスプローラからWebページにフォルダをD&Dしてアップロードしたい
  • ドロップターゲットはデザインしたい
  • Chromeで動けばよい

結論

  • フォルダのD&Dには<input type="file" webkitdirectory directory> を使う
  • ドロップターゲットはdivで好きにデザインする
  • デザインした<div>の上にtype="file"のinput要素をサイズ指定、opacity:0で配置する

試行錯誤の跡

まず、Chromeのinput type="file"にはエクスプローラからファイルをD&Dできる。すばらしいですねChrome。もうファイル選択ダイアログを開くことはない。

ここ によると webkitdirectory 属性を指定することでinput type="file"にフォルダを指定できるようだ。実際にはフォルダ内のファイルがすべて展開されて指定される。大量にファイルを含むフォルダを指定すると大変なことになるけどまぁそれはそれ。

 <input type="file" name="img" class="file" webkitdirectory directory>

次に input type="file" の見た目を変える方法を探す。ここらをみるとCSSでinputを透明にして上に重ねるのがいいらしい。前後関係を指定するのがcssのz-index。positionでrelativeやabsoluteを駆使する。よくわからないのでそのまま使わせてらおう。

でもこれだとinputのサイズを予測できない、と思ったら width, height指定が効くらしい。これで特定の領域のどこでもDropを有効にできる。どこをクリックしてもフォルダ選択ダイアログが表示される。

 <input type="file" name="img" class="file" webkitdirectory directory style="width: 100%; height: 100px">

あとはinputの下においたdivをデザインするとできあがり。D&Dした後はHTML5 のFile APIで好きに操作できる。

HTMLソースは次のようになった。

<html>
<head>
<style>
div.fileinputs {
 position: relative;
}

div.fakefile {
 position: absolute;
 top: 0px;
 left: 0px;
 z-index: 1;
 
 background-color: lightcoral;
 width: 100%;
 height: 100px;
 text-align: center;
 line-height: 100px;
 -webkit-border-radius: 10px;
}
input.file {
 position: relative;
 opacity: 0;
 z-index: 2;
 
 width: 100%;
 height: 100px;
}
</style>
</head>
<body>
<div class="fileinputs">
	<input type="file" name="img" class="file" webkitdirectory directory>
	<div class="fakefile">ここにフォルダをD&Dしてください</div>
</div>
</body>
</html>

inputのbackground-colorやopacityの値を変えると動作がよくわかる。

jQuery File Uploadも見たけどどこを変えればいいかわからなかった。