Vue.js 2.1 with TypeScript 2.1 on Payara Micro

この記事はPayara Advent Calendar 2016の21日目の記事です。

この記事はPayara MicroといいつつPayara Microの話はあんまりありません。
Payara Microの細かい話は蓮沼さんが書いていらっしゃるので、そちらをご覧頂ければと思います。

この記事には何ら説明なく
単語が出てくる可能性がございますが
分からなければ、ご質問ください。

構成

サーバーサイド

フロントエンド

VueでTypeScriptを利用するのであれば以下の記事が参考になると思います。
Vue.jsとTypeScript - Qiita

  • webpack 1.13.3 with vue-template-compiler-loader 1.0.4
  • tslint (前回の記事のeslintから乗り換えました。)

フォルダ構成

/─src
  ├─main
  │ ├─frontend // ここにTypeScriptのソース
  │ ├─java
  │ ├─resources
  │ │ └─META-INF
  │ │    └─persistence.xml
  │ └─webapp
  │   ├─index.html
  │   ├─style.css
  │   ├─bundle.js
  │   └─WEB-INF
  │      └─glassfish-resources.xml
  └─test
    └─java

機能

実装した機能をサーバーサイドとフロントエンドで
軽く列挙してみます。

サーバーサイド

  • 認証API
  • タスク一覧取得API
  • タスク追加API

フロントエンド

  • タスク追加
  • タスクツリー表示
  • 認証フォーム(めっちゃださい)っぽい何か

画面のスクショ

解説をしていく前にスクショだけ。

f:id:reteria:20161219034822p:plain

とりあえず色だけ適当にそれっぽくしておきました。
adobe color cc最高。
Adobe Color CC

認証

今回は認証付きのSPAっぽい何か、ということで
認証機能を作ります。

うらがみさんの資料を参考に書きます。
JAX-RS入門および実践

ContainerRequestFilterと独自アノテーションで認証の有無を設定します。
以下のような感じで設定します。

(一部抜粋)

  @POST
  @Path("login")
  @Produces(MediaType.APPLICATION_JSON)
  public User login(User user) {
    return manager.fetch()
      .orElseGet(() -> {
        User result = userManager.authenticate(user)
          .orElseThrow(() -> new InvalidAccessException("cannot login; please check your password or user name"));
        manager.setUser(result);
        return new User().setName(result.getName());
      });
  }
  @GET
  @Path("task")
  @Authenticated
  @Produces(MediaType.APPLICATION_JSON)
  public List<Task> tasklist(){
    return taskRepository.findAll();
  }
  

フィルタ処理

ここはざっくり、認証されているかどうかの判断はSessionScopeなオブジェクトが
Userオブジェクトを持っているかどうかで判断します。

もう少しRoleManagerにコード書いてたほうが良かったかもしれません。
isLoggedIn的なメソッドですね。

@SessionScoped
public class RoleManager implements Serializable {

  private static final long serialVersionUID = 1L;
  @Setter
  @Accessors(chain = true)
  private User user;

  public Optional<User> fetch() {
    return Optional.ofNullable(user);
  }

  /* 本当はあったら良かった
  public boolena isLoggedIn(){
    return user!=null
  }
  */

}

@Provider
@Authenticated
@ApplicationScoped
public class AuthenticationProvider implements ContainerRequestFilter {

  @Inject
  RoleManager manager;

  @Override
  public void filter(ContainerRequestContext context) throws IOException {
    if (!manager.fetch()
      .isPresent())
      context.abortWith(Response.status(Response.Status.UNAUTHORIZED)
        .entity("not permitted")
        .build());
  }

}

こんな感じですね。権限ないよって怒って固定文字列を返却するだけですね。

認証処理

では実際の認証処理ですが、こちらはBCryptを使ってDBと突合せします。
ざっくり引っ張ってきます。簡単でいいですね。

  // 認証処理(findっておかしいですね。)
  public Optional<User> find(User user) {
    return findByName(user.getName()).flatMap((findUser) -> {
      if (BCrypt.checkpw(user.getPassword(), findUser.getPassword())) {
        return Optional.of(findUser);
      }
      else {
        return Optional.empty();
      }
    });
  }

  // 一応、ユーザ登録処理のほうも一緒に。
  @Transactional
  public User register(User user) {
    if (!findByName(user.getName()).isPresent()) {
      User newUser = new User().setName(user.getName())
        .setPassword(BCrypt.hashpw(user.getPassword(), BCrypt.gensalt()));
      em.persist(newUser);
      return user;
    }
    throw new InvalidAccessException("permission denied");
  }

ユーザとその初期登録

ユーザの定義はこちらです。
passwordに入るのはBCryptによってencryptされたデータです。

@Data
@Accessors(chain = true)
@Entity
@NamedQueries({
  @NamedQuery(name="User.findByName",query="Select user from User user where user.name=:name")
})
public class User implements Serializable {
  private static final long serialVersionUID = 1L;

  @Id
  @GeneratedValue
  private Long id;
  @Column(unique = true)
  String name;
  String password;
}

初期登録の処理はこちらです。

今回は簡単に実装しているので、
とりあえずApplicationScopedにしておいて
@PostConstructでアプリケーション立ち上げ時に初期化することにします。

ユーザを起動時に登録しておきます。
また、今回はメモリDBなので気にせず毎回登録します。

@ApplicationScoped
public class UserManager {
  @Inject
  UserRepository repository;
  
  // ...省略

  @PostConstruct
  public void initialize() {
    User admin = new User().setName("admin")
      .setPassword("admin");
    repository.register(admin);
    User user = new User().setName("user")
      .setPassword("user");
    repository.register(user);
  }
}

大体こんなところです。認証処理が完成しました。

DB回りの設定はリンクだけ張っておきます。

payara-micro-example/persistence.xml at master · Wreulicke/payara-micro-example · GitHub

payara-micro-example/glassfish-resources.xml at master · Wreulicke/payara-micro-example · GitHub

ではフロントエンドから認証処理を叩きましょう。

export interface LoginInfo {
  name: string,
  password: string,
}

export async function login(o: LoginInfo) {
  // URLどうにかしたほうがいいけどとりあえず放置
  const response = await fetch("http://localhost:8080/api/example/login", {
    method: "POST",
    headers: {
      "content-type": "application/json",
    },
    credentials: "include",
    body: JSON.stringify(o),
  })
  return response.json()
}

fetch APIを使っています。
また、少しですがES8 async/awaitを使っています。
解説については他の記事に任せます。(質問があれば答えられる範囲であれば答えます。)

認証本体についてはこんなところです。

画面はこんな感じになりました。
gyazo.com

今回は記事ボリューム的に認証周りは大したことをやっていないので
このぐらいにしておきます。

タスクツリー

残りの細かい機能をまとめて、今回はタスクツリー表示機能を
関連するAPIと絡めて説明します。

こちらは公式のサンプルrewriteしたものになります。(少し見た目と動きを変えた)
再帰するコンポーネントですね。

機能一覧

タスクツリーの細かい機能としてはざっくり分類すると以下の3つです。

  • タスクの追加ができる
  • タスクの子供を追加できる。
  • タスクの子供の表示・非表示が切り替わる。

タスクツリー実装

今回はVueをTypeScriptでうまく扱うために、av-tsを使って実装しています。
github.com

av-tsを使ったVueコンポーネントの実装について少し説明してみます。
以下の画像の部分の定義になります。
f:id:reteria:20161219043020p:plain

import { Component, Lifecycle, Prop, Vue } from "av-ts"
import addTask, { Task } from "./module/addTask"
import TaskInput from "./TaskInput"

interface TaskNode {
  id: number
  name: string
  children?: TaskNode[]
}

@Component({
  name: "tree",
  components: { "task-input": TaskInput },
  // webpackでhtmlを読み込んでvueの関数にloaderで変換
  ...require("./tree.html"),
})
class Tree extends Vue {
  open = false
  name = ""
  isFolder = false
  @Prop model: TaskNode
  @Lifecycle mounted() {
    this.isFolder = this.model.children != null && this.model.children.length > 0
  }
  // クリックされたら閉じたり開いたりするアコーディオン用の処理
  toggleFolder() {
    this.open = !this.open
  }
  changeFolder() {
    if (!this.isFolder) {
      Vue.set(this.model, "children", [])
      this.isFolder = true
      this.open = true
    }
  }
  addTask(task: Task) {
    task.parent = this.model.id
    addTask(task).then((taskRes) => {
      this.model.children!.push({ id: taskRes.id, name: taskRes.name })
    })
  }
}

export default Tree

// tree.html
<li>
  <div v-on:click="toggleFolder" v-on:dblclick="changeFolder" v-bind:class="{item:!isFolder,folder:isFolder,tree:true}">
    {{model.name}}
  </div>
  <transition name="fade">
    <ul v-show="open" v-if="isFolder" class="none-style">
      <tree :model="m" v-for="m in model.children">
      </tree>
      <task-input :add="addTask" />
    </ul>
  </transition>
</li>

htmlはvue-template-compiler-loaderでloaderしているので
Vueのrender(と他にもなんかあるみたいですが)に変換されます。

av-tsのドキュメントを見た感じ
プロパティはdata()に
@Propでデコレートされているプロパティはpropsに
メソッドはmethodsに
getter/setterはcomputedに、といった形で
Vueの定義と等価になるようです。
@LifecycleでVueのライフサイクルのメソッドとして扱われます。

Vueの機能である、transitionを使ってfadein,fadeoutを実現しています。便利ですね。
https://jp.vuejs.org/v2/api/#transition

画面は以下のような感じになりました。

gyazo.com

まとめ

Payara-Microを使ってJAX-RSを実装した上に
Vue.js with TypeScriptで再帰的なコンポーネントを使って
タスクのツリーを定義するような画面を作りました。

再帰コンポーネントで苦労しました。
実際ハマったのはprops/propsDataの使い方だったりするのですが
最終的には使わなくなりました。

また、Vueでwarningは出るけどReactみたいにドキュメントのURLが出ないので
その辺出ると嬉しいなーと感じました。

av-tsを使った感じ、Vueのドキュメントとav-tsのドキュメントを行き来する感じで
少し辛い感じになりました。
この辺は他のTypeScriptで取り扱うためのライブラリを使っても
変わらないんじゃないかと思います。

普通にJSで.vueファイルを書いたほうが良いような気がしています。

リポジトリはこちらに置いてます。
github.com

完全にVue.jsの解説があまりできなかった感がありますが
その辺はこの辺で・・・
qiita.com

と思いましたがav-tsやvue-template-compiler-loaderについての説明は
あるのかな・・・?ないのかな・・・?

分からないところがあれば記事として書きますんで

番外編

素振りぐらいの気持ちでSelenideを使ってテストを書きました。
Selenideについてはうらがみさんの資料が詳しいです。
jQueryライクで分かりやすいですね。(jQueryが分からない人はごめんなさい・・・)

  @Test
  public void addTask() {
    login();
    val add = Selenide.$(By.cssSelector(".add:last-child"));
    add.click();
    add.$("input")
      .val("xxxx")
      .pressEnter();
    Selenide.$$(".item.tree").last()
      .should(Text.text("xxxx"));
  }