この記事は、裏freee Developers Advent Calender 2018の19日目です。
こんにちは。
freeeでGYOMUハックをやっているtamurashingoです。
仕事ではRubyを使っていますが、裏なのでJavaについて書こうと思います。
動機
- Ruby on Railsのルーティングが難しかった
- SpringFrameworkはまだよかったよな〜
- でもSpringFrameworkどれくらい理解していたっけ?
から、コントローラっぽいものを作ってみようと思いました。
Springっぽいやつ → Spring以前 → 春の前 → 正月? → なんかおめでたすぎるから七草がゆにしよう
ということでNanakusagayuFrameworkです。
Mavenには登録していないので、ダウンロードして mvn install
すれば使えるようになります。
手順
- あるパッケージ配下のクラスファイルを全部読み込む
-
@Controller
アノテーションを探す -
@GET
アノテーションを探す (今回はGET
のみ対応させます) - パスと呼び出すメソッドのマッピングを作成
- jetty起動
- アクセスがあった際に4.のマッピングに応じてメソッドを起動
とりあえずアノテーション
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Controller {
String value() default "";
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface GET {
String value() default "";
}
RetentionPolicy.RUNTIME
にしておきます。
パッケージ配下のクラスファイルの取得
こんな構成です。
初期化メイン
/**
* 基準となるクラスが属するパッケージ配下のクラス一覧を取得する
*
*/
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から完全なクラス名を取得
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
オブジェクトを作っています。
Collections.list(entries).stream()
.filter(isClassfile)
.filter(hasPackage)
.map(convertFilename)
.collect(Collectors.toList());
- 拡張子が
.class
であるものを - ディレクトリの区切り(/)をドット(.)にしたものがベースのパッケージ名から始まっているものを
- クラス名からFully Qualified Nameを生成したものを
- リストにまとめる
ということをしています。
コントローラの取得
ファイル名の一覧ができたので、この一覧の中からコントローラを取得します。
ちょっと長いですが一気に載せます。
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
@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
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
/**
* 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で動く様に作っています。
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)
の部分です。
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をいくつか用意して、コントローラ以外にも対応できる様に一応考えてあります。
(が、若干手抜きなのは否めない)
実行
コントローラを作って・・・
@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>");
}
}
メインを作って
public class Main {
public static void main(String...args) throws Exception {
NanakusagayuApplication.run(Main.class, args);
}
}
実行して http://localhsot:3344/test/say にアクセスすると・・・
きました。
おわりに
- コントローラのコンストラクタはデフォルトコンストラクタのみ許容
- SpringFrameworkはコンストラクタインジェクションも可
- コントローラのメソッドの引数は
HttpServletRequest
,HttpServletResponse
固定- SpringFrameworkはBeanだったりパスパラメータなど事前にリクエストを変換して渡すこともできる
- GETにしか対応していない
- SpringFrameworkはPOSTやPUTなどにも
上記の制限があるにもかかわらず、結構たいへんでした。
ただ、バイトコードをいじるとかではなく、Javaの標準機能でここまでできるんだな〜というのを感じました。
ほんとは mvn package
したらexecutableなFatJarが作られるようにしたかったのですが、アドベントカレンダーまでに間に合わず断念しました。
明日20日は kei-0226 さんが難しいドメインに立ち向かう話をしてくれるそうです。
楽しみです。