Railsが難しかったので息抜きにSpringFrameworkのコントローラっぽいものを作った

この記事は、[裏freee Developers Advent Calender 2018](https://adventar.org/calendars/3140)の19日目です。

こんにちは。
freeeでGYOMUハックをやっているtamurashingoです。
仕事ではRubyを使っていますが、裏なのでJavaについて書こうと思います。


# 動機
- Ruby on Railsのルーティングが難しかった
- SpringFrameworkはまだよかったよな〜
- でもSpringFrameworkどれくらい理解していたっけ？

から、コントローラっぽいものを作ってみようと思いました。

Springっぽいやつ → Spring以前 → 春の前 → 正月？ → なんかおめでたすぎるから七草がゆにしよう
ということでNanakusagayuFrameworkです。

https://github.com/tamurashingo/NanakusagayuFramework

Mavenには登録していないので、ダウンロードして `mvn install` すれば使えるようになります。


# 手順
1. あるパッケージ配下のクラスファイルを全部読み込む
2. `@Controller`アノテーションを探す
3. `@GET`アノテーションを探す （今回は`GET`のみ対応させます）
4. パスと呼び出すメソッドのマッピングを作成
5. jetty起動
6. アクセスがあった際に4.のマッピングに応じてメソッドを起動


## とりあえずアノテーション

```java:Controller.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Controller {
    String value() default "";
}
```

```java:GET.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface GET {
    String value() default "";
}
```

`RetentionPolicy.RUNTIME`にしておきます。


## パッケージ配下のクラスファイルの取得

こんな構成です。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/13469/5bad5229-22d8-1baf-84e4-f2524c7823a3.png)

### 初期化メイン

```java:Initializer.java
/**
 * 基準となるクラスが属するパッケージ配下のクラス一覧を取得する
 *
 */
public class Initializer {

    /**
     * 基準となるクラスを指定して、クラス一覧を取得する。
     *
     * @param className 基準となるクラス名
     * @return クラス一覧
     * @throws InitializerException クラス一覧の取得に失敗
     */
    public List<String> getClassList(String className) throws InitializerException {
        return getClassnames(className);
    }

    /**
     * クラス情報からclassファイル名を得る
     * @param cls クラス情報
     * @return ファイル名
     */
    private String getPathnameFromClass(Class<?> cls) {
        return cls.getCanonicalName().replace(".", "/") + ".class";
    }

    private List<String> getClassnames(String className) throws InitializerException {
        try {
            // クラス名からクラスがある場所を取得する
            Class<?> baseClass = Class.forName(className);
            ClassLoader cl = baseClass.getClassLoader();
            String pathname = getPathnameFromClass(baseClass);
            URL url = cl.getResource(pathname);

            if (url == null) {
                throw new InitializerException("not found class:" + baseClass.getCanonicalName());
            }

            // クラスとその場所からクラス名クローラを取得する
            AbstractClassnameCrawler parser = ClassnameCrawlerFactory.create(baseClass, url);
            // クラス名の一覧を取得する
            return parser.getClassnameList();
        } catch (ClassNotFoundException ex) {
            throw new InitializerException("初期化失敗", ex);
        }
    }
}
```

これはパッケージの読み込みの基準となるクラスを渡すと、そのパッケージ配下のクラス一覧を取得するものです。
基準クラスがjarファイルの中にあるのか、classesみたいなディレクトリにあるのかで読み込み方法が若干違うので、`AbstractClassnameClawer`を作ってそちらで処理しています。


次にjarファイルの中にある場合の実装を見ていきます。（ファイルのほうもほぼ同じ作りです）

### Jarから完全なクラス名を取得

```java:ClassnameCrawlerFromJar.java
public class ClassnameCrawlerFromJar extends AbstractClassnameCrawler {

    /** 基準クラスが所属するパッケージ */
    private String basePackageName;

    public ClassnameCrawlerFromJar(Class<?> baseClass, URL baseUrl) {
        super(baseClass, baseUrl);
        this.basePackageName = baseClass.getPackage().getName();
    }

    /**
     * ファイルかつ拡張子が .class かどうか
     */
    Predicate<JarEntry> isClassfile = jarFile -> !jarFile.isDirectory() && jarFile.getName().endsWith(".class");

    /**
     * 基準クラスが所属するパッケージ（配下）かどうか
     */
    Predicate<JarEntry> hasPackage = jarFile -> jarFile.getName().replace("/", ".").startsWith(basePackageName);

    /**
     * JarEntry(com/github/xxxx/xxx/XXXX.class)をクラス名(com.github.xxxx.xxx.XXXX)に変換する
     */
    Function<JarEntry, String> convertFilename = jarFile -> {
        String filename = jarFile.getName();
        // com/github/xxxx/xxx/XXXX.class -> com/github/xxxx/xxx/XXXX
        filename = filename.substring(0, filename.lastIndexOf(".class"));
        // com/github/xxxx/xxx/XXXX -> com.github.xxxx.xxx.XXXX
        return filename.replace("/", ".");
    };

    @Override
    public List<String> getClassnameList() throws InitializerException {
        String path = baseUrl.getPath(); // file:/path/to/jarfile!/path/to/class
        String jarPath = path.substring(5, path.indexOf("!")); // /path/to/jarfile
        try (JarFile jar = new JarFile(URLDecoder.decode(jarPath, StandardCharsets.UTF_8.name()))) {
            Enumeration<JarEntry> entries = jar.entries();
            return Collections.list(entries).stream()
                    .filter(isClassfile)
                    .filter(hasPackage)
                    .map(convertFilename)
                    .collect(Collectors.toList());
        } catch (IOException ex) {
            throw new InitializerException("ファイル読み込みエラー:" + jarPath, ex);
        }
    }
}
```

`getClassnameList`を見ていきます。

jarのURL(file:/path/to/jarfile!/path/to/class)からjarの実ファイル名(/path/to/jarfile)を取得し、`JarFile`オブジェクトを作っています。

```java
Collections.list(entries).stream()
    .filter(isClassfile)
    .filter(hasPackage)
    .map(convertFilename)
    .collect(Collectors.toList());
```

- 拡張子が `.class` であるものを
- ディレクトリの区切り(/)をドット(.)にしたものがベースのパッケージ名から始まっているものを
- クラス名からFully Qualified Nameを生成したものを
- リストにまとめる

ということをしています。


## コントローラの取得

ファイル名の一覧ができたので、この一覧の中からコントローラを取得します。
ちょっと長いですが一気に載せます。

```java:ControllerScanner.java
public class ControllerScanner implements ComponentScanner {

    /**
     * ルーティグ情報
     * String: /path
     * Object[0]: Controller instance
     * Object[1]: method instance
     */
    private Map<String, Object[]> pathMethodMap = new HashMap<>();

    public Map<String, Object[]> getRoute() {
        return this.pathMethodMap;
    }

    @Override
    public void componentScan(Class<?> cls) throws InitializerException {
        Controller controller = cls.getAnnotation(Controller.class);
        if (controller == null) {
            return;
        } else {
            createController(cls, controller);
        }
    }

    /**
     * ルーティング情報を作成する
     *
     * @param cls
     * @param controller
     * @param <T>
     * @throws InitializerException
     */
    private <T> void createController(Class<?> cls, Controller controller) throws InitializerException {
        T inst = createInst(cls);
        getPathAndMethod(inst, controller.value());
    }

    /**
     * クラスをインスタンス化する。
     * (今は)デフォルトコンストラクタのみ対応。
     *
     * @param cls クラス情報
     * @param <T> ダミーパラメータ
     * @return インスタンス
     * @throws InitializerException インスタンス化に失敗
     */
    private <T> T createInst(Class<?> cls) throws InitializerException {
        try {
            return (T)cls.getDeclaredConstructor().newInstance();
        } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException ex) {
            throw new InitializerException("コントローラ作成に失敗:" + cls.getCanonicalName(), ex);
        }
    }

    /**
     * GETメソッドを取得し、ルーティングを作成する
     * @param inst     Controllerのインスタンス
     * @param basePath Controllerで定義したパス
     * @param <T>      ダミー情報
     * @throws InitializerException
     */
    private <T> void getPathAndMethod(T inst, final String basePath) throws InitializerException {
        Class<?> cls = inst.getClass();

        for (Method method: cls.getDeclaredMethods()) {
            GET get = method.getAnnotation(GET.class);
            if (get == null) {
                continue;
            }
            String path = get.value();

            StringBuilder buf = new StringBuilder();
            if (basePath.isEmpty()) {
                if (path.isEmpty()) {
                    buf.append("/");
                } else if (!path.startsWith("/")) {
                    buf.append("/").append(path);
                } else {
                    buf.append(path);
                }

            } else {
                if (!basePath.startsWith("/")) {
                    buf.append("/");
                }
                buf.append(basePath);
                if (!path.isEmpty()) {
                    if (!path.startsWith("/")) {
                        buf.append("/");
                    }
                    buf.append(path);
                }
            }

            pathMethodMap.put(buf.toString(), new Object[]{ inst, method });
        }
    }
}
```

細かく見ていきます。


### componentScan

```java
@Override
public void componentScan(Class<?> cls) throws InitializerException {
    Controller controller = cls.getAnnotation(Controller.class);
    if (controller == null) {
       return;
    } else {
       createController(cls, controller);
    }
}
```

`cls.getAnnotation(Controller.class)` で `Controller`アノテーションがあればそれを持ってきます。

### createController と createInst

```java
private <T> void createController(Class<?> cls, Controller controller) throws InitializerException {
    T inst = createInst(cls);
    getPathAndMethod(inst, controller.value());
}

/**
 * クラスをインスタンス化する。
 * (今は)デフォルトコンストラクタのみ対応。
 *
 * @param cls クラス情報
 * @param <T> ダミーパラメータ
 * @return インスタンス
 * @throws InitializerException インスタンス化に失敗
 */
private <T> T createInst(Class<?> cls) throws InitializerException {
    try {
        return (T)cls.getDeclaredConstructor().newInstance();
    } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException ex) {
        throw new InitializerException("コントローラ作成に失敗:" + cls.getCanonicalName(), ex);
    }
}
```

メソッドの先頭に`<T>`と付いていますが、コンパイラを騙すためのダミーです。
デフォルトコンストラクタを取得して、インスタンス化しています。

最近の本家SpringFrameworkだとコンストラクタでDIするのが推奨されている様ですが、そのためにはコンストラクタの種類を色々と調べないといけないので本家は大変ですね。

### getPathAndMethod

```java
/**
 * GETメソッドを取得し、ルーティングを作成する
 * @param inst     Controllerのインスタンス
 * @param basePath Controllerで定義したパス
 * @param <T>      ダミー情報
 * @throws InitializerException
 */
private <T> void getPathAndMethod(T inst, final String basePath) throws InitializerException {
    Class<?> cls = inst.getClass();

    for (Method method: cls.getDeclaredMethods()) {
        GET get = method.getAnnotation(GET.class);
        if (get == null) {
            continue;
        }
        String path = get.value();

        StringBuilder buf = new StringBuilder();
        if (basePath.isEmpty()) {
            if (path.isEmpty()) {
                buf.append("/");
            } else if (!path.startsWith("/")) {
                buf.append("/").append(path);
            } else {
                buf.append(path);
            }

        } else {
            if (!basePath.startsWith("/")) {
                buf.append("/");
            }
            buf.append(basePath);
            if (!path.isEmpty()) {
                if (!path.startsWith("/")) {
                    buf.append("/");
                }
                buf.append(path);
            }
        }

        pathMethodMap.put(buf.toString(), new Object[]{ inst, method });
    }
}
```


まず`GET`アノテーションがあるメソッドの一覧を取得します。
その後、`Controller`に定義したパスと`GET`に定義したパスを組み合わせて実際のパスを生成しています。
最後にパスとそのパスにアクセスしてきた時に起動するインスタンスとメソッドをmapに格納します。

## ルータ

リクエストを受け取って然るべきインスタンスのメソッドを呼び出すやつです。
Jettyで動く様に作っています。

```java:Rotuer.java
public class Router extends AbstractHandler {

    /**
     * ルーティング情報
     * String: /path
     * Object[0]: Controller instance
     * Object[1]: method instance
     */
    private Map<String, Object[]> routing;

    public Router(Map<String, Object[]> routing) {
        this.routing = routing;
    }

    @Override
    public void handle(String s, Request request, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws IOException, ServletException {
        if (!routing.containsKey(s)) {
            throw new ServletException("page not found");
        }
        Object[] inst = routing.get(s);

        try {
            Method method = (Method) inst[1];
            httpServletResponse.setStatus(HttpServletResponse.SC_OK);
            method.invoke(inst[0], httpServletRequest, httpServletResponse);
            request.setHandled(true);
        } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException ex) {
            throw new ServletException(ex);
        }
    }
}
```

とりたてて説明することもないのですが、Jettyだと第一引数にパスがくるので`HttpServletRequest`とか覗かなくていいのが楽です。

## 起動ポイント

いわゆる `SpringApplication.run(xxx.class, args)` の部分です。

```java:NanakusagayuApplication.java
public class NanakusagayuApplication {

    private static ControllerScanner controllerScanner = new ControllerScanner();

    public static void run(Class<?> cls, String[] args) throws Exception {
        Initializer init = new Initializer();

        // クラス一覧の取得
        List<String> classList = init.getClassList(cls.getCanonicalName());
        init(classList);

        startServer();
    }

    private static void init(List<String> classList) throws InitializerException {
        ComponentScanner[] scannerList = new ComponentScanner[] {
                controllerScanner
        };

        try {
            for (String clsName : classList) {
                Class<?> cls = Class.forName(clsName);
                for (ComponentScanner scanner : scannerList) {
                    scanner.componentScan(cls);
                }
            }
        } catch (ClassNotFoundException ex) {
            throw new InitializerException(ex);
        }
    }

    private static void startServer() throws Exception {
        Server server = new Server(3344);
        server.setHandler(new Router(controllerScanner.getRoute()));
        server.start();
        server.join();
    }
}
```

ComponentScannerをいくつか用意して、コントローラ以外にも対応できる様に一応考えてあります。
（が、若干手抜きなのは否めない）

# 実行

コントローラを作って・・・

```java:TestController.java
@Controller("/test")
public class TestController {

    @GET("/say")
    public void hello(HttpServletRequest req, HttpServletResponse res) throws IOException {
        res.setContentType("text/html; charset=UTF-8");
        PrintWriter out = res.getWriter();
        out.println("<h1>こんにちは</h1>");
    }
}
```

メインを作って

```java:Main.java
public class Main {
    public static void main(String...args) throws Exception {
        NanakusagayuApplication.run(Main.class, args);
    }
}
```

実行して http://localhsot:3344/test/say にアクセスすると・・・

![待望のhello world](https://qiita-image-store.s3.amazonaws.com/0/13469/66e39065-3f82-741c-4be2-9b0b6fef06e9.png)

きました。

# おわりに

- コントローラのコンストラクタはデフォルトコンストラクタのみ許容
    - SpringFrameworkはコンストラクタインジェクションも可
- コントローラのメソッドの引数は`HttpServletRequest`,`HttpServletResponse`固定
    - SpringFrameworkはBeanだったりパスパラメータなど事前にリクエストを変換して渡すこともできる
- GETにしか対応していない
    - SpringFrameworkはPOSTやPUTなどにも

上記の制限があるにもかかわらず、結構たいへんでした。
ただ、バイトコードをいじるとかではなく、Javaの標準機能でここまでできるんだな〜というのを感じました。

ほんとは `mvn package` したらexecutableなFatJarが作られるようにしたかったのですが、アドベントカレンダーまでに間に合わず断念しました。


明日20日は kei-0226 さんが難しいドメインに立ち向かう話をしてくれるそうです。
楽しみです。