LoginSignup
27
30

More than 5 years have passed since last update.

Javaで超簡易Webフレームワークを作ってみよう

Last updated at Posted at 2017-08-21

概要

今日、 Web アプリケーション界隈では「クラウドネイティブ」や「マイクロサービス」といったワードをよく聞くようになりました。Java による開発では、これらのアーキテクチャを実現するために Spring Boot などのフレームワークを利用する場面が多いです。

しかしそれほど Java の動作について詳しくない状態で、いざいきなりこのようなフレームワークを使って開発を始めると「なぜこんな書き方で動くの?」「アノテーション?なにそれ」「DI?ぬるぽじゃないの?」といった状態に陥ることになりかねません。

この記事では、そのような疑問を少しでも払拭するために、超似非簡易フレームワークを自分で作成してみて、少しでも Java のフレームワークの内部の仕組みを理解できればよいと考えています。

最終的に実現するクライアントサンプル

Spring Boot のクイックスタート https://projects.spring.io/spring-boot/#quick-start を参考に、今回実現するフレームワークを利用するクライアントプログラムは下記の通りです。

src/main/java/hello/SampleController.java
package hello;

...<import省略>

@Controller
public class SampleController {

    @Resource
    private SampleService service;

    @RequestMapping("/hello")
    public String home() {
        return service.hello();
    }

    public static void main(String[] args) {
        EasyApplication.run(SampleController.class, args);
    }
}

このクライアントプログラムを実行し、 http クライアント(curlなど)から呼び出した際に「Hello World!」と表示されることを期待します。

$ curl http://localhost:8080/hello
Hello World!

※ ここまでの動作を見て下記項目が理解出来ている方は本稿記載のレベルは既に理解されているため、このまま読み進めても新たな発見は無い可能性があります。

  • 各種アノテーションがリフレクションAPIでどのように扱われているか想像できる
  • なぜ service フィールドが new によるインスタンス生成をしていないにも関わらず、service.hello() が NullPointerException にならずに成功するかわかる
  • main メソッドは run を実行しているだけなのに、なぜ "/hello" で home() メソッドが呼ばれるかわかる

※注意:本サンプルにはこれ以上の機能は無く、HTTP の仕様に沿って実装するわけでもないので、フレームワークとしての実用性は全くありません。前述したように、フレームワークの内部で行われていることを少しでも理解することをゴールとします。

前提

  • JDK 8 以上がインストールされている環境であること。
  • Git がインストールされている環境であること。

利用する技術や単語

  • Gradle …… 作成したプログラムをビルド(コンパイル・実行)するために利用します。Maven というビルドツールを聞いたことがあるかもしれませんが、こちらは Maven のように XML で設定定義するのではなく Groovy 言語を利用しプログラマブルにビルドに関する設定ができるため、プログラマと親和性が高いです。

(随時追記)

ソースコード

https://github.com/hatimiti/easyframework
下記手順により、ソースコードをローカル環境へ配置してください。

$ cd ~
$ mkdir java
$ git clone https://github.com/hatimiti/easyframework.git
$ cd easyframework

実行してみる

# Gradle で実行を行う。初回実行時は時間がかかる。
$ ./gradlew clean build run

> Task :run
Registered Components => {class hello.SampleServiceImpl=hello.SampleServiceImpl@723279cf, interface hello.SampleService=hello.SampleServiceImpl@723279cf, class hello.SampleController=hello.SampleController@10f87f48}
Registered Controller => /hello - { "path": "/hello", "instance": hello.SampleController@10f87f48", "method": public java.lang.String hello.SampleController.home()" }
<============-> 93% EXECUTING [16s]
> :run

上記状態になったら、別のコンソールを立ち上げて curl や wget などで確認する。

$ curl http://localhost:8080/hello
Hello World!

※ 実行が成功したら元のコンソールは ctrl + C で終了してください。

ファイル構造

ソースコードの構成は下記のようになっています。

~/java/easyframework/src
|--main
|  |--java
|  |  |--easyframework
|  |  |  |--Component.java
|  |  |  |--Controller.java
|  |  |  |--EasyApplication.java
|  |  |  |--RequestMapping.java
|  |  |--hello
|  |  |  |--SampleController.java
|  |  |  |--SampleService.java
|  |  |  |--impl
|  |  |  |  |--SampleServiceImpl.java

簡易構成図




図1: 構成

サンプルは Client (利用者) 側と、Framework (提供者) 側に分かれています。読み進める際は、どちらのレイヤーのことを指しているかを常に意識するようにしてください。

アノテーション

アノテーションは、Java SE 5 で導入された機能です。
普段目につくものは「@Override」「@Deprecated」「@SuppressWarnings」や、Java 8 で追加された「@FunctionalInterface」あたりではないでしょうか。これらもアノテーションです。

アノテーションは「注釈型」とも呼ばれ、その名の通り、ソースコードに注釈を加える(意味付けする)型です。例えば「@Override」であれば、そのメソッドは「 super クラスからオーバーラーライドしたもの」であることを意味付けしますし、「@Deprecated」であれば、そのメソッドや型は「非推奨なもの」ということをソースコード上で表すことができます。

アノテーションは型扱いのため、自作するには「class」や「interface」と同様に「@interface」というキーワードを用いて定義します。

public @interface Hoge {
}

アノテーションは属性情報も定義することができます。

public @interface Fuga {
    String value() default "";
}

属性を持つことでアノテーション利用時に追加情報を与えることができます。

@Fuga(value = "/hello")
public void x() {
}

属性が value という名前で、かつ value のみ指定する場合は属性名を省略することができます。

@Fuga("/hello")
public void x() {
}

定義時に default でデフォルト値を設定しておくと、指定を省略することができます。

@Fuga 
public void x() {
}

以降は、本稿で必要となるアノテーション定義です。
※ 定義の詳細は後述します。

src/main/java/easyframework/Controller.java
package easyframework;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.Inherited;

@Inherited
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Controller {
}
src/main/java/easyframework/Component.java
package easyframework;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.Inherited;

@Inherited
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Component {
}
src/main/java/easyframework/RequestMapping.java
package easyframework;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.Inherited;

@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
  String value() default "";
}

では、これらアノテーションを利用することで何が便利になるのでしょうか?
たしかにサンプルプログラムでは、

@Controller
public class SampleController {
...

のように、通常のクラス定義の上部に先程作成した @Controller が付加されています。

ソースコードに注釈を加えるということですが、それだけであれば Javadoc を利用してコメントすれば良いだけです。
そうです、Javadoc と異なるのは、アノテーションはコメントではなくソースコードの一部ということです。
ソースコードの一部であるため、後述するリフレクションAPIを利用することで対象のアノテーションがどのように機能するかを自身で定義することができるようになります。
逆に言うと、単にアノテーションを付加するだけでは何も機能せず、それこそ Javadoc と同じ(またはそれ以下の) 扱いとなってしまいます。

ここでは、独自のアノテーションを扱いたい場合は、リフレクションAPIも利用する必要があるということが分かれば良いです。

メタアノテーション

上記したアノテーション定義では、アノテーション定義自体にアノテーションが付加されていました。
このようなアノテーションにつけるアノテーションのことをメタアノテーションといいます。

# 名称 属性 概要
1 @Inherited - 当アノテーションを付加したクラスの子クラスにも注釈情報が継承されることを示します。
2 @Target ElementType 当アノテーションを、ソース上のどの箇所に付加できるかを ElementType で示します。
3 @Retention RetentionPolicy 当アノテーション情報をどの時点まで保持するかを RetentionPolicy で示します。
# 属性 概要
1 ElementType ANNOTATION_TYPE 注釈型
2 CONSTRUCTOR コンストラクタ
3 FIELD フィールド
4 LOCAL_VARIABLE ローカル
5 METHOD メソッド
6 PACKAGE パッケージ
7 PARAMETER 引数
8 TYPE クラス、インタフェース、enum
9 TYPE_PARAMETER ジェネリクス(型パラメータ)
10 TYPE_USE 型を使用しているあらゆる箇所
11 RetentionPolicy SOURCE コンパイル時: ○
class ファイル: ×
実行時: ×
12 CLASS コンパイル時: ○
class ファイル: ○
実行時: ×
13 RUNTIME コンパイル時: ○
class ファイル: ○
実行時: ○

RetentionPolicy については、リフレクションAPIから読み取る場合は「RUNTIME」にしておく必要があるため、自然と RUNTIME 指定が多くなると思います。今回作成したアノテーションも全て RUNTIME を指定しています。

リフレクションAPI

以降のサンプルでは、リフレクションAPI (以下リフレクション)を利用するため、フレームワーク部分の説明に入る前に紹介をしておきます。
リフレクションとは Java で「メタプログラミング」を実現するためのAPIであり、プログラム内のメタ情報を扱うことができます。メタ情報とは主に、クラス名や、クラスの型、どのパッケージに定義されているか、どのようなフィールドを持っていてその型は何か、どのようなメソッドが定義されていて戻り値は何かなどの、クラス定義(.class)から読取ることができる情報です。
また、リフレクションでは ( private を含めた) フィールドに値を設定したり、メソッドを実行することができます。
そのため、リフレクションを使うと、良くも悪くも Java の構文やカプセル化を無視して、Java プログラムを制御することができます。フレームワークでは少なからずこのリフレクションを用いることで、利用者(クライアント)側が楽に開発できるように設計されています。

ここで、リフレクションを利用したサンプルを見てみましょう。
( Java 9 の REPL である jshell を利用しています )
String クラスのインスタンスを呼び出して、標準出力に "Hello, Reflection." と表示する例です。

リフレクションを利用しない場合
$ jshell
jshell> System.out.println(new String("Hello, Reflection."))
Hello, Reflection.
リフレクションを利用した場合
$ jshell
jshell> Class<String> strClass = String.class
strClass ==> class java.lang.String

jshell> java.lang.reflect.Constructor c = strClass.getConstructor(String.class)
c ==> public java.lang.String(java.lang.String)

jshell> System.out.println(c.newInstance("Hello, Reflection."))
Hello, Reflection.

リフレクションを利用しない例では String クラスのインスタンスを new キーワードを用いて生成していますが、リフレクションを利用した場合の例では new キーワードは用いず Constructor 型の newInstance() メソッドを呼ぶことで生成しています。
また、Constructor インスタンスの基となっているのは String.class で生成した、Class 型のインスタンスです。
Class 型については次項で説明します。

このように、クラスの定義情報を基に、インスタンスの生成やメソッド呼び出しが可能になります。

java.lang.Class 型

Class 型のインスタンスを取得する方法

Class 型のインスタンスを取得する方法は大きく分けて3つあります。

  1. <型>.class: 型定義から取得する
  2. Class.getClass(): インスタンスから取得する
  3. Class#forName(String): 文字列から取得する
// <型>.class: 型定義から取得する
jshell> Class<?> c1 = String.class
c1 ==> class java.lang.String

// Class.getClass(): インスタンスから取得する
jshell> Class<?> c2 = "Hello".getClass()
c2 ==> class java.lang.String

// Class#forName(String): 文字列から取得する
jshell> Class<?> c3 = Class.forName("java.lang.String")
c3 ==> class java.lang.String

// 取得されたクラス情報は同一
jshell> c1 == c2
$13 ==> true

jshell> c2 == c3
$14 ==> true

jshell> c1.equals(c2)
$15 ==> true

jshell> c2.equals(c3)
$16 ==> true

特に「<型>.class」の指定は、ログ出力時のロガー生成の際によく見かけるのではないでしょうか。

Logger LOG = LoggerFactory.getLogger(String.class);

Class クラスに定義された主なメソッド

前述の方法で取得した Class インスタンスを利用することで、クラスに関する様々なメタ情報を得ることができます。
下記は、Class クラスに定義されている主なメソッド群です。
本サンプルで利用しているものを抜粋しました。

※ Javadoc ( https://docs.oracle.com/javase/jp/8/docs/api/java/lang/Class.html ) より引用

# 戻り値 メソッド static 概要
1 Class<?> forName(String className) 指定された文字列名を持つクラスまたはインタフェースに関連付けられた、Classオブジェクトを返します。
2 Annotation[] getAnnotations() この要素に存在する注釈を返します。
3 <A extends Annotation> A getAnnotation(Class<A> annotationClass) 存在する場合は、この要素の指定された型の注釈を返し、そうでない場合はnullを返します。
4 Field[] getDeclaredFields() このClassオブジェクトが表すクラスまたはインタフェースによって宣言されたすべてのフィールドをリフレクトするFieldオブジェクトの配列を返します。
5 Field[] getFields() このClassオブジェクトが表すクラスまたはインタフェースのすべてのアクセス可能なpublicフィールドをリフレクトする、Fieldオブジェクトを保持している配列を返します。
6 Method[] getDeclaredMethods() このClassオブジェクトによって表されるクラスまたはインタフェースのすべての宣言されたメソッドをリフレクトするMethodオブジェクトが格納された配列を返します。これには、public、protected、デフォルト(package)アクセスおよびprivateメソッドが含まれますが、継承されたメソッドは除外されます。
7 Method[] getMethods() このClassオブジェクトによって表されるクラスまたはインタフェースのすべてのpublicメソッドをリフレクトするMethodオブジェクトを格納している配列を返します。これには、クラスまたはインタフェースで宣言されたもの、およびスーパー・クラスやスーパー・インタフェースから継承されたものも含まれます。
8 boolean isInterface() 指定されたClassオブジェクトがインタフェース型を表すかどうかを判定します。
9 boolean isAnnotationPresent(Class<? extends Annotation> annotationClass) 指定された型の注釈がこの要素に存在する場合はtrueを返し、そうでない場合はfalseを返します。
10 T newInstance() このClassオブジェクトが表すクラスの新しいインスタンスを作成します。
Stringクラスのメソッドを取得する例
$ jshell
jshell> String.class
$1 ==> class java.lang.String

// getMethods() は private メソッドは含まれないが、親クラスから継承した定義も含む
jshell> Arrays.asList($1.getMethods()).forEach(System.out::println)
public boolean java.lang.String.equals(java.lang.Object)
public int java.lang.String.length()
public java.lang.String java.lang.String.toString()
public int java.lang.String.hashCode()
public void java.lang.String.getChars(int,int,char[],int)
public int java.lang.String.compareTo(java.lang.Object)
public int java.lang.String.compareTo(java.lang.String)
public int java.lang.String.indexOf(int)
public int java.lang.String.indexOf(java.lang.String)
public int java.lang.String.indexOf(java.lang.String,int)
public int java.lang.String.indexOf(int,int)
public static java.lang.String java.lang.String.valueOf(int)
public static java.lang.String java.lang.String.valueOf(char)
public static java.lang.String java.lang.String.valueOf(boolean)
public static java.lang.String java.lang.String.valueOf(float)
public static java.lang.String java.lang.String.valueOf(double)
public static java.lang.String java.lang.String.valueOf(java.lang.Object)
public static java.lang.String java.lang.String.valueOf(long)
public static java.lang.String java.lang.String.valueOf(char[])
public static java.lang.String java.lang.String.valueOf(char[],int,int)
public java.util.stream.IntStream java.lang.String.codePoints()
public boolean java.lang.String.isEmpty()
public char java.lang.String.charAt(int)
public int java.lang.String.codePointAt(int)
public int java.lang.String.codePointBefore(int)
public int java.lang.String.codePointCount(int,int)
public int java.lang.String.offsetByCodePoints(int,int)
public byte[] java.lang.String.getBytes(java.nio.charset.Charset)
public byte[] java.lang.String.getBytes()
public byte[] java.lang.String.getBytes(java.lang.String) throws java.io.UnsupportedEncodingException
public void java.lang.String.getBytes(int,int,byte[],int)
public boolean java.lang.String.contentEquals(java.lang.CharSequence)
public boolean java.lang.String.contentEquals(java.lang.StringBuffer)
public boolean java.lang.String.equalsIgnoreCase(java.lang.String)
public int java.lang.String.compareToIgnoreCase(java.lang.String)
public boolean java.lang.String.regionMatches(int,java.lang.String,int,int)
public boolean java.lang.String.regionMatches(boolean,int,java.lang.String,int,int)
public boolean java.lang.String.startsWith(java.lang.String)
public boolean java.lang.String.startsWith(java.lang.String,int)
public boolean java.lang.String.endsWith(java.lang.String)
public int java.lang.String.lastIndexOf(java.lang.String,int)
public int java.lang.String.lastIndexOf(java.lang.String)
public int java.lang.String.lastIndexOf(int,int)
public int java.lang.String.lastIndexOf(int)
public java.lang.String java.lang.String.substring(int,int)
public java.lang.String java.lang.String.substring(int)
public java.lang.CharSequence java.lang.String.subSequence(int,int)
public java.lang.String java.lang.String.concat(java.lang.String)
public java.lang.String java.lang.String.replace(java.lang.CharSequence,java.lang.CharSequence)
public java.lang.String java.lang.String.replace(char,char)
public boolean java.lang.String.matches(java.lang.String)
public boolean java.lang.String.contains(java.lang.CharSequence)
public java.lang.String java.lang.String.replaceFirst(java.lang.String,java.lang.String)
public java.lang.String java.lang.String.replaceAll(java.lang.String,java.lang.String)
public java.lang.String[] java.lang.String.split(java.lang.String)
public java.lang.String[] java.lang.String.split(java.lang.String,int)
public static java.lang.String java.lang.String.join(java.lang.CharSequence,java.lang.CharSequence[])
public static java.lang.String java.lang.String.join(java.lang.CharSequence,java.lang.Iterable)
public java.lang.String java.lang.String.toLowerCase(java.util.Locale)
public java.lang.String java.lang.String.toLowerCase()
public java.lang.String java.lang.String.toUpperCase(java.util.Locale)
public java.lang.String java.lang.String.toUpperCase()
public java.lang.String java.lang.String.trim()
public java.util.stream.IntStream java.lang.String.chars()
public char[] java.lang.String.toCharArray()
public static java.lang.String java.lang.String.format(java.util.Locale,java.lang.String,java.lang.Object[])
public static java.lang.String java.lang.String.format(java.lang.String,java.lang.Object[])
public static java.lang.String java.lang.String.copyValueOf(char[])
public static java.lang.String java.lang.String.copyValueOf(char[],int,int)
public native java.lang.String java.lang.String.intern()
public final void java.lang.Object.wait(long,int) throws java.lang.InterruptedException
public final void java.lang.Object.wait() throws java.lang.InterruptedException
public final native void java.lang.Object.wait(long) throws java.lang.InterruptedException
public final native java.lang.Class java.lang.Object.getClass()
public final native void java.lang.Object.notify()
public final native void java.lang.Object.notifyAll()

// getDeclaredMethods() は private メソッドを含むが、親クラスから継承した定義は含まない
jshell> Arrays.asList($1.getDeclaredMethods()).forEach(System.out::println)
byte java.lang.String.coder()
public boolean java.lang.String.equals(java.lang.Object)
public int java.lang.String.length()
public java.lang.String java.lang.String.toString()
public int java.lang.String.hashCode()
public void java.lang.String.getChars(int,int,char[],int)
public int java.lang.String.compareTo(java.lang.Object)
public int java.lang.String.compareTo(java.lang.String)
public int java.lang.String.indexOf(int)
static int java.lang.String.indexOf(byte[],byte,int,java.lang.String,int)
public int java.lang.String.indexOf(java.lang.String)
public int java.lang.String.indexOf(java.lang.String,int)
public int java.lang.String.indexOf(int,int)
static void java.lang.String.checkIndex(int,int)
public static java.lang.String java.lang.String.valueOf(int)
public static java.lang.String java.lang.String.valueOf(char)
public static java.lang.String java.lang.String.valueOf(boolean)
public static java.lang.String java.lang.String.valueOf(float)
public static java.lang.String java.lang.String.valueOf(double)
public static java.lang.String java.lang.String.valueOf(java.lang.Object)
public static java.lang.String java.lang.String.valueOf(long)
public static java.lang.String java.lang.String.valueOf(char[])
public static java.lang.String java.lang.String.valueOf(char[],int,int)
private static java.lang.Void java.lang.String.rangeCheck(char[],int,int)
public java.util.stream.IntStream java.lang.String.codePoints()
public boolean java.lang.String.isEmpty()
public char java.lang.String.charAt(int)
public int java.lang.String.codePointAt(int)
public int java.lang.String.codePointBefore(int)
public int java.lang.String.codePointCount(int,int)
public int java.lang.String.offsetByCodePoints(int,int)
public byte[] java.lang.String.getBytes(java.nio.charset.Charset)
void java.lang.String.getBytes(byte[],int,byte)
public byte[] java.lang.String.getBytes()
public byte[] java.lang.String.getBytes(java.lang.String) throws java.io.UnsupportedEncodingException
public void java.lang.String.getBytes(int,int,byte[],int)
public boolean java.lang.String.contentEquals(java.lang.CharSequence)
public boolean java.lang.String.contentEquals(java.lang.StringBuffer)
private boolean java.lang.String.nonSyncContentEquals(java.lang.AbstractStringBuilder)
public boolean java.lang.String.equalsIgnoreCase(java.lang.String)
public int java.lang.String.compareToIgnoreCase(java.lang.String)
public boolean java.lang.String.regionMatches(int,java.lang.String,int,int)
public boolean java.lang.String.regionMatches(boolean,int,java.lang.String,int,int)
public boolean java.lang.String.startsWith(java.lang.String)
public boolean java.lang.String.startsWith(java.lang.String,int)
public boolean java.lang.String.endsWith(java.lang.String)
public int java.lang.String.lastIndexOf(java.lang.String,int)
public int java.lang.String.lastIndexOf(java.lang.String)
public int java.lang.String.lastIndexOf(int,int)
public int java.lang.String.lastIndexOf(int)
static int java.lang.String.lastIndexOf(byte[],byte,int,java.lang.String,int)
public java.lang.String java.lang.String.substring(int,int)
public java.lang.String java.lang.String.substring(int)
public java.lang.CharSequence java.lang.String.subSequence(int,int)
public java.lang.String java.lang.String.concat(java.lang.String)
public java.lang.String java.lang.String.replace(java.lang.CharSequence,java.lang.CharSequence)
public java.lang.String java.lang.String.replace(char,char)
public boolean java.lang.String.matches(java.lang.String)
public boolean java.lang.String.contains(java.lang.CharSequence)
public java.lang.String java.lang.String.replaceFirst(java.lang.String,java.lang.String)
public java.lang.String java.lang.String.replaceAll(java.lang.String,java.lang.String)
public java.lang.String[] java.lang.String.split(java.lang.String)
public java.lang.String[] java.lang.String.split(java.lang.String,int)
public static java.lang.String java.lang.String.join(java.lang.CharSequence,java.lang.CharSequence[])
public static java.lang.String java.lang.String.join(java.lang.CharSequence,java.lang.Iterable)
public java.lang.String java.lang.String.toLowerCase(java.util.Locale)
public java.lang.String java.lang.String.toLowerCase()
public java.lang.String java.lang.String.toUpperCase(java.util.Locale)
public java.lang.String java.lang.String.toUpperCase()
public java.lang.String java.lang.String.trim()
public java.util.stream.IntStream java.lang.String.chars()
public char[] java.lang.String.toCharArray()
public static java.lang.String java.lang.String.format(java.util.Locale,java.lang.String,java.lang.Object[])
public static java.lang.String java.lang.String.format(java.lang.String,java.lang.Object[])
public static java.lang.String java.lang.String.copyValueOf(char[])
public static java.lang.String java.lang.String.copyValueOf(char[],int,int)
public native java.lang.String java.lang.String.intern()
private boolean java.lang.String.isLatin1()
static void java.lang.String.checkOffset(int,int)
static void java.lang.String.checkBoundsOffCount(int,int,int)
static void java.lang.String.checkBoundsBeginEnd(int,int,int)
static byte[] java.lang.String.access$100(java.lang.String)
static boolean java.lang.String.access$200(java.lang.String)

// getFields() は private フィールドは含まれないが、親クラスから継承した定義も含む
jshell> Arrays.asList($1.getFields()).forEach(System.out::println)
public static final java.util.Comparator java.lang.String.CASE_INSENSITIVE_ORDER

// getDeclaredFields() は private を含むが、親クラスから継承した定義は含まない
jshell> Arrays.asList($1.getDeclaredFields()).forEach(System.out::println)
private final byte[] java.lang.String.value
private final byte java.lang.String.coder
private int java.lang.String.hash
private static final long java.lang.String.serialVersionUID
static final boolean java.lang.String.COMPACT_STRINGS
private static final java.io.ObjectStreamField[] java.lang.String.serialPersistentFields
public static final java.util.Comparator java.lang.String.CASE_INSENSITIVE_ORDER
static final byte java.lang.String.LATIN1
static final byte java.lang.String.UTF16

リフレクション用の型定義

Class クラス以外のリフレクション型定義は主に java.lang.reflect パッケージ配下に定義されています。
こちらも本サンプルで使用しているものを抜粋しました。

# クラス 戻り値 メソッド 概要
1 Field boolean isAnnotationPresent(Class<? extends Annotation> annotationClass) 指定された型の注釈がこの要素に存在する場合はtrueを返し、そうでない場合はfalseを返します。
2 void setAccessible(boolean flag) このオブジェクトのaccessibleフラグを、指定されたboolean値に設定します。
3 void set(Object obj, Object value) このFieldオブジェクトによって表される指定されたオブジェクト引数のフィールドを、指定された新しい値に設定します。
4 Method boolean isAnnotationPresent(Class<? extends Annotation> annotationClass) 指定された型の注釈がこの要素に存在する場合はtrueを返し、そうでない場合はfalseを返します。
5 <T extends Annotation> T getAnnotation(Class<T> annotationClass) 存在する場合は、この要素の指定された型の注釈を返し、そうでない場合はnullを返します。
6 Object invoke(Object obj, Object... args) このMethodオブジェクトによって表される基本となるメソッドを、指定したオブジェクトに対して指定したパラメータで呼び出します。

特記すべき点としては下記です。

  • private な情報にアクセスする場合は setAccessible(true) を呼び出す必要がある。
  • フィールドに値をセットする場合は field.set(対象インスタンス, 設定したい値) を呼び出す。
  • メソッドを実行したい場合は method.invoke(対象インスタンス, 引数...) を呼び出す。

リフレクションを使う上での注意

  • フレームワーク層でのみ利用しましょう
    • 上記で見たとおり、private な内容まで書き換えれてしまうため、一般開発者がリフレクションを使い出すと、本来想定しているクラス設計や思想が崩れてしまう可能性があります。実際の開発ではフレームワーク層や共通層を管理するメンバーにお願いして修正してもらうのが良いと思います。
  • パフォーマンス面で注意しましょう
    • リフレクションは実行時に定義情報を読み取って処理を行うため、通常の処理と比べるとパフォーマンスがよくありません。そのため、あるフレームワークではコンパイルをトリガーとして .class や .java を生成することで、実行時の影響を減らしたりしています。
    • それでも利用する必要がある場合は、リフレクションで読み取ったフィールド(Field)やメソッド(Method)情報をキャッシュするなど検討しましょう。
  • 脆弱性を生み出さないようセキュリティを意識しましょう
    • リフレクションは文字列からインスタンス生成したりできるため、クライアントからのリクエストパラメータをリフレクションAPIへ渡すなどをしてしまうと思いもよらない脆弱性を生み出すことになるため、外部からのアクセスに注意しましょう。

フレームワーク

以降フレームワークの中心となる EasyFramework.java の構成について紹介していきます。
まずは本フレームワーク唯一の public メソッドである run() メソッドです。

src/main/java/easyframework/EasyApplication.java
package easyframework;

public class EasyApplication {

...
      public static void run(Class<?> clazz, String... args) {
          scanComponents(clazz);
          injectDependencies();
          registerHTTPPaths();
          startHTTPServer();
      }
...

}

下記の 4 つのメソッドを呼び出しています。

  • scanComponents(clazz) …… コンポーネントを探索して登録します。
  • injectDependencies() …… 登録されたコンポーネントを該当のフィールドへDIします。
  • registerHTTPPaths() …… 登録されたコンポーネントの内、HTTP のコールポイントとなるパスを登録します。
  • startHTTPServer() …… サーバーを起動し、リクエストを待ち受けます。

それぞれの詳細について記載します。

コンポーネントスキャン

本フレームワークでは、後述する HTTP リクエストの実行ポイント(Controller)や、DI を実現するために、フレームワーク内部で必要なインスタンス群を管理します。ここではこれらフレームワークの管理下にあるインスタンスのことを「コンポーネント」と呼ぶこととします。
Spring Boot でも同様に @ComponentScan アノテーションを利用することで、管理すべきコンポーネントを自動で見つけてくれます。
その際、どのクラスをコンポーネントとして管理するかを「@Controller」や「@Component」アノテーションが付加されているかどうかで判断します。

ここでは探索した結果を EasyApplication の components フィールドへ登録します。

src/main/java/easyframework/EasyApplication.java
public class EasyApplication {

    /** KEY=the class of the component, VALUE= A instance of the component */
    private final static Map<Class<?>, Object> components = new HashMap<>();

...
src/main/java/easyframework/EasyApplication.java
    private static List<Class<?>> scanClassesUnder(String packageName) {
        // パッケージ名を . 区切りを / 区切りに変換する。
        String packagePath = packageName.replace('.', '/');
        // クラスローダから対象パスのリソースを検索する。
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        URL root = cl.getResource(packagePath);

        // .class ファイルを取得する。
        File[] files = new File(root.getFile())
            .listFiles((dir, name) -> name.endsWith(".class"));
        return Arrays.asList(files).stream()
                .map(file -> packageName + "." + file.getName().replaceAll(".class$", ""))
                // package 名 + クラス名を基に、Class クラスインスタンを取得して List として返す。
                .map(fullName -> uncheck(() -> Class.forName(fullName)))
                .collect(Collectors.toList());
    }
src/main/java/easyframework/EasyApplication.java
    // @Component アノテーションと @Controller アノテーションが付加された型をコンポーネントとして扱う
    private static boolean hasComponentAnnotation(Class<?> clazz) {
        return clazz.isAnnotationPresent(Component.class)
                || clazz.isAnnotationPresent(Controller.class);
    }

    private static void scanComponents(Class<?> source) {
        List<Class<?>> classes = scanClassesUnder(source.getPackage().getName());
        classes.stream()
            .filter(clazz -> hasComponentAnnotation(clazz))
            .forEach(clazz -> uncheck(() -> {
                // コンポーネント対象の型のインスタンスを生成して登録する
                Object instance = clazz.newInstance();
                components.put(clazz, instance);
            }));
        System.out.println("Registered Components => " + components);
    }

DI(Dependency Injection)

最初に示したサンプルで SampleService 型のインスタンスを new していないにも関わらず、メソッド呼び出し時に NullPointerException が発生していなかったのは、この DI の仕組みを利用したからです。

src/main/java/hello/SampleController.java
    @Resource
    private SampleService service; //← new していないので null のはずでは?

    @RequestMapping("/hello")
    public String home() {
        return service.hello(); //← なぜ NullPointerException が発生しない!?
    }

DI=依存性の注入と訳すことができますが、依存性とは何でしょうか?

ここでは依存性=オブジェクト(インスタンス)と考えてください。
そのため、DIとはオブジェクトを注入する仕組みのことで、クラスとクラスの間を疎結合化することができます。
クラス間の疎結合化をするためのデザインパターンに FactoryMethod がありますが、DIはファクトリメソッドや new キーワードすら使う必要が無いため、さらにもう一段階疎結合化できます。

本フレームワークで DI している箇所は下記の処理です。

src/main/java/easyframework/EasyApplication.java
    private static void injectDependencies() {
        // 事前にスキャン・登録したコンポーネント(components)全てのフィールドを確認する。
        components.forEach((clazz, component) -> {
            Arrays.asList(clazz.getDeclaredFields()).stream()
                // @Resource アノテーションが付加されたフィールドのみ取得する。
                .filter(field -> field.isAnnotationPresent(Resource.class))
                .forEach(field -> uncheck(() -> {
                    // private なフィールドでも扱えるように acccessible とする。
                    field.setAccessible(true);
                    // コンポーネント(components)から該当の型のインスタンスを取得し、注入(設定)する。
                    field.set(component, components.get(field.getType()));
                }));
        });
    }

今回、注入しようとしている SampleService の定義は、下記のように @Component が付加されているため、コンポーネントとして登録済です。

src/main/java/hello/impl/SampleServiceImpl.java
@Component
public class SampleServiceImpl implements SampleService {
...

注入先である SampleController のフィールドには @Resource が付加されており、かつ型が合致するため、先程見た injectDependencies() メソッドでインスタンスが注入されます。

src/main/java/hello/SampleController.java
@Controller
public class SampleController {

    @Resource
    private SampleService service; //←ここにフレームワーク管理下のインスタンスが注入される

...

この仕組により、service.hello() を呼び出しても、NullPointerException は発生することはありません。

DIの仕組みを導入してくれるフレームワークのことを「DIコンテナ」と呼びます。
SpringFramework、Google Guice、Seasar2、などがDIコンテナにあたります。

DIを導入することで得られるメリットは下記のようなものです。

  • クラス間の結合部分のインスタンス生成コード(newキーワード)がなくなり、必要なタイミングで必要なインスタンスを注入(代入)することができる。
    • 外部ファイル(XMLなど)で、注入するオブジェクトを定義することで、後に拡張するポイントとすることができる。
  • 注入するインスタンスのスコープをフレームワークで制御できる。
    • スコープは1つのインスタンスの有効範囲のことです。
      • singleton: 全体で1つのインスタンスを共有する。
      • prototype: 注入の度にインスタンスを生成する。
      • session: 同一セッション中は同一のインスタンスを共有する。
      • request: 同一リクエスト中は同一のインスタンスを共有する。
    • 特に singleton を選択した場合、複数のリクエスト(マルチスレッド環境)でも1つのインスタンスを共有するため、singleton で管理するオブジェクトのフィールドに可変のフィールドを持たせることは避けましょう。可変なフィールドを保持する場合などは prototype で扱う必要があります。
  • Framework から AOP をかけやすくなる。
    • フレームワークがインスタンスを管理することで、横断的な処理を挟みやすくなる。

HTTP リクエスト

ここでは、HTTPクライアントから「 http://localhost:8080/hello 」と呼び出された際のコールポイントの登録処理を扱います。
フレームワークで該当処理を行っている箇所は下記です。

結果は EasyApplication の controllers フィールドへ登録します。

src/main/java/easyframework/EasyApplication.java
public class EasyApplication {

...
    /** KEY=path, VALUE= A instance and methods */
    private final static Map<String, HTTPController> controllers = new HashMap<>();

...
src/main/java/easyframework/EasyApplication.java
    private static void registerHTTPPaths() {
        // フレームワーク管理下のコンポーネント全てを確認する。
        components.entrySet().stream()
            // その内、@Controller が付加されているクラスに限定する。
            .filter(kv -> kv.getKey().isAnnotationPresent(Controller.class))
            .forEach(kv ->
                // Controller のメソッドを取得する。
                Arrays.asList(kv.getKey().getMethods()).stream()
                    // @RequestMapping アノテーションが付加されているメソッドに限定する
                    .filter(m -> m.isAnnotationPresent(RequestMapping.class))
                    .forEach(m -> {
                        // RequestMapping アノテーションの情報を取得する
                        RequestMapping rm = m.getAnnotation(RequestMapping.class);
                        // HTTPのコールポイントを設定する属性 value フィールドを取得する。( rm.value() )
                        HTTPController c = new HTTPController(rm.value(), kv.getValue(), m);
                        // Controller として登録する。
                        controllers.put(rm.value(), c);
                        System.out.println("Registered Controller => " + rm.value() + " - " + c);
                    })
            );
    }

HTTPクライアントからコネクションを待ち受けて、該当 Controller のメソッドを呼び出す箇所は下記です。

    private static class EasyHttpServer implements Closeable {
...
        public void start() {
            while (true) {
                acceptRequest:
                // クライアントからのリクエストを待ち受ける。( server.accept() )
                try (Socket socket = server.accept();
                        BufferedReader br = new BufferedReader(
                            new InputStreamReader(socket.getInputStream(), "UTF-8"))) {

                    // br.readLine() => GET /hello HTTP/1.1
                    String path = br.readLine().split(" ")[1];
                    try (PrintStream os = new PrintStream(socket.getOutputStream())) {
                        // リクエスト中のパス /hello を基にして、登録済みの Controller を取り出す。
                        HTTPController c = controllers.get(path);
                        if (c == null) {
                            os.println("404 Not Found (path = " + path + " ).");
                            break acceptRequest;
                        }
                        // Controller のメソッドを呼び出す。( method.invoke )
                        os.println(c.method.invoke(c.instance));
                    }

                } catch (IOException | IllegalAccessException | InvocationTargetException e) {
                    throw new IllegalStateException(e);
                }
            }
        }
...
    }

AOP

AOP は Aspect Oriented Programming のことで「横断的関心事」に着目したプログラム手法です。オブジェクト指向では、ここのオブジェクトに特化した処理を、個別の定義(クラスなど)にまとめて扱いますが、どうしてもそれらを跨いだ処理(横断的関心事)が必要になることがあります。
例えば...

  • ログ出力
  • トランザクション管理
  • 認証処理
  • トークンチェック
  • 処理時間の計測
  • バリデーションチェック

などです。

AOPを使うと、業務プログラム層であるクライアントプログラムを編集せずとも、横断的に処理を挟み込むことができます。

ここでは AOP を実現するために、Java に標準で用意されている「 java.lang.reflect.Proxy 」と「 java.lang.reflect.InvocationHandler 」を利用した方法を紹介します。

Proxy#newProxyInstance() メソッドに、元となるオブジェクトと、挟み込みたい横断的処理(InvocationHandler)を指定すると、その処理が組み込まれた新しいインスタンスが返されます。
ここではその作成されたインスタンスを DI 用インスタントして上書きすることで AOP を実現しています。

Proxy を利用して AOP を行う場合は、対象の型はインターフェースを実装している必要があります。
ここでは SampleService インターフェースに対してインターセプトするようにします。



src/main/java/easyframework/EasyApplication.java
    private static void registerAOP() {
        components.entrySet().stream()
            .filter(kv -> kv.getKey().isInterface())
            .forEach(kv -> components.put(kv.getKey(),
                Interceptor.createProxiedTarget(kv.getValue())));
        System.out.println("Registered AOP => " + components);
    }
src/main/java/easyframework/EasyApplication.java
package easyframework;

public class EasyApplication {

...
      public static void run(Class<?> clazz, String... args) {
          scanComponents(clazz);
          registerAOP(); //← この部分を追加
          injectDependencies();
          registerHTTPPaths();
          startHTTPServer();
      }
...

}
src/main/java/easyframework/Interceptor.java
package easyframework;

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

class Interceptor implements InvocationHandler {

    private final Object target;

    public static Object createProxiedTarget(Object target) {
        return Proxy.newProxyInstance(
            target.getClass().getClassLoader(),
            target.getClass().getInterfaces(),
            new Interceptor(target));
    }

    private Interceptor(Object obj) {
        this.target = obj;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
...
    }

}
  • Proxy では、必ずインターセプトする箇所は interface を用意する必要があるため、業務プログラムでは下記のようなライブラリを利用するのがよいでしょう。

ハンズ・オン

インターセプターの追加

  1. easyframework.Transactional アノテーションを作成してください。
  2. hello.SampleService インターフェースに @Transactional アノテーションを付加してください。
    • 型自身に付加した場合
    • 公開メソッドに付加した場合
  3. easyframework.Interceptor の invoke メソッドを下記の仕様に沿って実装してください。
$ git checkout -b handsonAOP remotes/origin/handsonAOP
src/main/java/easyframework/Interceptor.java

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {

        if (method.getDeclaringClass() == Object.class) {
            return method.invoke(target, args);
        }

        return invoke(method, args);
    }

    private Object invoke(Method method, Object[] args) throws Throwable {
        /*
         * 対象のメソッド、または型定義に @Transactional アノテーションが付加されている場合に
         * 下記の動作をする処理をインターセプトしてください。
         * 1. 該当のメソッド実行前に "Starts transaction." を標準出力に表示する。
         * 2. 該当のメソッド実行が正常終了した場合 "Commit transaction." を標準出力に表示する。
         * 3. 該当のメソッド実行が例外終了した場合 "Rollbak transaction." を標準出力に表示し、例外を上位に throw する。
         *
         * ※ @Transactional が付加されていない場合は、該当のメソッドの実行結果のみ返してください。
         * ※ @Transactional アノテーションは easyframework.Transactional.java として新規作成してください。
         * ※ ./gradlew clean build run で実行し、curl http://localhost:8080/hello で確認してください。
         */
         return null;
    }

解答は addAOP ブランチで。

$ git checkout remotes/origin/addAOP
$ less src/main/java/easyframework/Interceptor.java

Spring Boot の依存関係

参考に、Spring Boot のサンプルの spring-boot-sample-tomcat プロジェクトがどのようなライブラリ群で構成されているか確認します。

$ git clone https://github.com/spring-projects/spring-boot.git
$ cd spring-boot/spring-boot-samples/spring-boot-sample-tomcat/
$ mvn dependency:tree
[INFO] Scanning for projects...
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] Building Spring Boot Tomcat Sample 2.0.0.BUILD-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO]
[INFO] --- maven-dependency-plugin:2.10:tree (default-cli) @ spring-boot-sample-tomcat ---
[INFO] org.springframework.boot:spring-boot-sample-tomcat:jar:2.0.0.BUILD-SNAPSHOT
[INFO] +- org.springframework.boot:spring-boot-starter:jar:2.0.0.BUILD-SNAPSHOT:compile
[INFO] |  +- org.springframework.boot:spring-boot:jar:2.0.0.BUILD-SNAPSHOT:compile
[INFO] |  +- org.springframework.boot:spring-boot-autoconfigure:jar:2.0.0.BUILD-SNAPSHOT:compile
[INFO] |  +- org.springframework.boot:spring-boot-starter-logging:jar:2.0.0.BUILD-SNAPSHOT:compile
[INFO] |  |  +- ch.qos.logback:logback-classic:jar:1.2.3:compile
[INFO] |  |  |  \- ch.qos.logback:logback-core:jar:1.2.3:compile
[INFO] |  |  +- org.slf4j:jul-to-slf4j:jar:1.7.25:compile
[INFO] |  |  \- org.slf4j:log4j-over-slf4j:jar:1.7.25:compile
[INFO] |  +- org.springframework:spring-core:jar:5.0.0.RC3:compile
[INFO] |  |  \- org.springframework:spring-jcl:jar:5.0.0.RC3:compile
[INFO] |  \- org.yaml:snakeyaml:jar:1.18:runtime
[INFO] +- org.springframework.boot:spring-boot-starter-tomcat:jar:2.0.0.BUILD-SNAPSHOT:compile
[INFO] |  +- org.apache.tomcat.embed:tomcat-embed-core:jar:8.5.16:compile
[INFO] |  +- org.apache.tomcat.embed:tomcat-embed-el:jar:8.5.16:compile
[INFO] |  \- org.apache.tomcat.embed:tomcat-embed-websocket:jar:8.5.16:compile
[INFO] +- org.springframework:spring-webmvc:jar:5.0.0.RC3:compile
[INFO] |  +- org.springframework:spring-aop:jar:5.0.0.RC3:compile
[INFO] |  +- org.springframework:spring-beans:jar:5.0.0.RC3:compile
[INFO] |  +- org.springframework:spring-context:jar:5.0.0.RC3:compile
[INFO] |  +- org.springframework:spring-expression:jar:5.0.0.RC3:compile
[INFO] |  \- org.springframework:spring-web:jar:5.0.0.RC3:compile
[INFO] \- org.springframework.boot:spring-boot-starter-test:jar:2.0.0.BUILD-SNAPSHOT:test
[INFO]    +- org.springframework.boot:spring-boot-test:jar:2.0.0.BUILD-SNAPSHOT:test
[INFO]    +- org.springframework.boot:spring-boot-test-autoconfigure:jar:2.0.0.BUILD-SNAPSHOT:test
[INFO]    +- com.jayway.jsonpath:json-path:jar:2.4.0:test
[INFO]    |  +- net.minidev:json-smart:jar:2.3:test
[INFO]    |  |  \- net.minidev:accessors-smart:jar:1.2:test
[INFO]    |  |     \- org.ow2.asm:asm:jar:5.0.4:test
[INFO]    |  \- org.slf4j:slf4j-api:jar:1.7.25:compile
[INFO]    +- junit:junit:jar:4.12:test
[INFO]    +- org.assertj:assertj-core:jar:3.8.0:test
[INFO]    +- org.mockito:mockito-core:jar:2.8.47:test
[INFO]    |  +- net.bytebuddy:byte-buddy:jar:1.6.14:test
[INFO]    |  +- net.bytebuddy:byte-buddy-agent:jar:1.6.14:test
[INFO]    |  \- org.objenesis:objenesis:jar:2.5:test
[INFO]    +- org.hamcrest:hamcrest-core:jar:1.3:test
[INFO]    +- org.hamcrest:hamcrest-library:jar:1.3:test
[INFO]    +- org.skyscreamer:jsonassert:jar:1.5.0:test
[INFO]    |  \- com.vaadin.external.google:android-json:jar:0.0.20131108.vaadin1:test
[INFO]    \- org.springframework:spring-test:jar:5.0.0.RC3:test
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1.507 s
[INFO] Finished at: 2017-08-20T17:50:33+09:00
[INFO] Final Memory: 21M/309M
[INFO] ------------------------------------------------------------------------

本フレームワークで実現できていない制約

  • DIコンポーネントは全てシングルトン
  • DIはフィールドインジェクションのみ
  • DIコンポーネントはデフォルトコンストラクタのみで生成される
  • Request パラメータも受け取れず、バリデータも不可
  • HTTP メソッドは GET のみで、POST や PUT は受け取れない
  • 複雑な AOP はできない
  • HotDeploy (Hot Reloading) 不可

他にもいっぱい。

参考にさせていただいた本

本稿で述べたような内容のさらなる詳細は、下記の本に記載されています。
興味がある方は読んでみてください。

<Javaフレームワーク開発入門>


参考にさせていただいたサイト

27
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
30