作って理解するWebフレームワーク

前回、簡単なDIコンテナを作ってみたので、次はこれを使ってWebフレームワークを作ってみたいと思います。

Webサーバーをつくる

まず、WebフレームワークなのでHTTPサーバーが必要ですね。なので簡単なものを作ります。
とりあえずブラウザからリクエストを受け取ったら200 OKとHTMLを返すだけのサーバーです。
今回は、そこらのブラウザからアクセスできればいいや、ということで、RFCとかの仕様に準拠することは考えません。

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSoc = new ServerSocket(8989);
        for (;;) {
            Socket s = serverSoc.accept();
            new Thread(() -> {
                try (InputStream is = s.getInputStream();
                     BufferedReader bur = new BufferedReader(new InputStreamReader(is))) 
                {
                    String firstLine = bur.readLine();
                    for (String line; (line = bur.readLine()) != null && !line.isEmpty(););
                    try (OutputStream os = s.getOutputStream();
                         PrintWriter pw = new PrintWriter(os))
                    {
                        pw.println("HTTP/1.0 200 OK");
                        pw.println("Content-Type: text/html");
                        pw.println();
                        pw.println("<h1>Hello!</h1>");
                        pw.println(LocalDateTime.now());
                    }
                } catch (IOException ex) {
                    System.out.println(ex);
                }
            }).start();
        }
    }
}


First Web Server by kishida · Pull Request #1 · kishida/tinydi

基本的なMVC

こんな感じでコントローラを登録できるMVCフレームワークを作ります。

@Named
@Path("")
public class IndexController {
    @Path("index")
    public String index() {
        return "<h1>Hello</h1>" + LocalDateTime.now();
    }
    
    @Path("message")
    public String mes() {
        return "<h1>Message</h1>Nice to meet you!";
    }
}


まず、コントローラークラスにつけるアノテーション

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD, ElementType.TYPE})
public @interface Path {
    String value();
}


Contextから登録クラスをひっぱり出せるようにしておきます。

    public static Collection<Map.Entry<String, Class>> registeredClasses() {
        return types.entrySet();
    }


URLパスと処理メソッドを対応づけるための構造体を作ります。

    @AllArgsConstractor
    static class ProcessorMethod {
        String name;
        Method method;
    }


@Pathアノテーションがついたクラスの、@Pathアノテーションがついたメソッドを拾い集めておきます。

        Context.autoRegister();
        Map<String, ProcessorMethod> methods = new HashMap<>();
        Context.registeredClasses().forEach(entry -> {
            Class cls = entry.getValue();
            Path rootAno = (Path) cls.getAnnotation(Path.class);
            if (rootAno == null) {
                return; // continue
            }
            String root = trimSlash(rootAno.value());
            for (Method m : cls.getMethods()) {
                Path pathAno = m.getAnnotation(Path.class);
                if (pathAno == null) {
                    continue;
                }
                String path = root + "/" + pathAno.value();
                methods.put(path, new ProcessorMethod(entry.getKey(), m));
            }
        });

そしたら、HTTPリクエストの1行目「GET /index HTTP/1.1」とかになっているので、メソッド・パス・プロトコルを分離します。

        Pattern pattern = Pattern.compile("([A-Z]+) ([^ ]+) (.+)");
        String first = bur.readLine();
        Matcher mat = pattern.matcher(first);
        mat.find();
        String httpMethod = mat.group(1);
        String path = mat.group(2);
        String protocol = mat.group(3);


とってきたパスから対応するJavaメソッドを取ってきます。

ProcessorMethod method = methods.get(path);

パスと処理を結びつける、ルーティング処理ですね。
実際のフレームワークでは、ここがかなりキモになります。なので、作りこんでいくと、ここが肥大化するはず。


みつからなければNot Foundですね。

    if (method == null) {
        pw.println("HTTP/1.0 404 Not Found");


みつかったら、対応するビーンをとってきて、メソッドを呼び出します。

        Object bean = Context.getBean(method.name);
        Object output = method.method.invoke(bean);


んで、その結果を返します。

        pw.println("HTTP/1.0 200 OK");
        pw.println("Content-Type: text/html");
        pw.println();
        pw.println(output);


これで、なんかMVCフレームワークのような動きになります。
First MVC by kishida · Pull Request #3 · kishida/tinydi

リクエスト情報のインジェクト

そしたら、リクエスト情報を処理時に取れるようにインジェクトできる仕組みを作っておきましょうか。
リクエスト情報を格納する構造体を作っておきます。

@Named
@RequestScoped
@Data
public class RequestInfo {
    private String path;
    private InetAddress localAddress;
    private String userAgent;
}


@Namedをつけたので、コンテナには登録されるはずです。なので、リクエスト処理のときにオブジェクトを取得して、パスとかをつっこんでやります。

    RequestInfo info = (RequestInfo) Context.getBean("requestInfo");
    info.setLocalAddress(s.getLocalAddress());
    info.setPath(path);


ついでに、リクエストヘッダーを分解して、User-Agentを取るようにしておきます。

    Pattern patternHeader = Pattern.compile("([A-Za-z-]+): (.+)");
    for (String line; (line = bur.readLine()) != null && !line.isEmpty();) {
        Matcher matHeader = patternHeader.matcher(line);
        if (matHeader.find()) {
            switch (matHeader.group(1)) {
                case "User-Agent":
                    info.setUserAgent(matHeader.group(2));
                    break;
            }
        }
    }


そうすると、こんな感じでリクエスト情報がとれるようになります。

@Named
@Path("info")
public class RequestInfoController {
    @Inject
    RequestInfo requestInfo;
    
    @Path("index")
    public String index() {
        return String.format("<h1>Info</h1>Host:%s<br/>Path:%s<br/>UserAgent:%s<br/>",
                requestInfo.getLocalAddress(), 
                requestInfo.getPath(), 
                requestInfo.getUserAgent());
    }
}


ちょろちょろっと書いたにしては、なんかすごくまっとうな動きをしててびっくりです。DIコンテナにオブジェクトを管理してもらうと、いろんなことが楽になるんですね。
これが、DIコンテナを中心としていろんな機能のフレームワークが実装される理由だと思います。


Request Info by kishida · Pull Request #7 · kishida/tinydi

セッションIDを振る

さて、ここまでできて足りないのはセッションの仕組みです。セッションの仕組みを実装するには、ブラウザに対してセッションIDを割り当ててやる必要があります。
そのために、クッキーという仕組みが使われますね。レスポンスヘッダーに「Set-Cookie: foo=hoge」とか書いておくと、次のリクエストからブラウザが「Cookie: foo=hoge」とか付けてリクエストしてくれるようになる、なんかブラウザってかわいいなって思うようになる仕組みのことです。


まず現在値を覚えておくためAtomicLongを用意します。マルチスレッド対応でえらい。

        AtomicLong lastSessionId = new AtomicLong();

実際には、HashMapをそのまま使ってたり、各所マルチスレッド対応してないところだらけなのですが、今回はスレッドセーフとか気にせずに行きます。それはそれでがんばれ。


リクエストヘッダーからクッキーを取ってきます。

    case "Cookie":
        Stream.of(value.split(";"))
              .map(exp -> exp.trim().split("="))
              .filter(kv -> kv.length == 2)
              .forEach(kv -> cookies.put(kv[0], kv[1]));


クッキーにセッションIDがあればその値、なければ新しい値を取ってきます。

    String sessionId = cookies.getOrDefault("jsessionid", 
                                            Long.toString(lastSessionId.incrementAndGet()));

実際にはこのように単純に連番を振ってしまうと、セッションIDを横取りしてしまうセッションハイジャックができてしまうので、タイムスタンプなどをまぜつつハッシュ化などの処理をしないといけません。
開発時に再起動したとき、ブラウザに前のIDが残ってたりするのが不便なので、この段階でやってしまっててもよかった。


まあともかく、生成したセッションIDをクッキーとしてレスポンスヘッダーに埋め込みます。

    pw.println("HTTP/1.0 200 OK");
    pw.println("Content-Type: text/html");
    pw.println("Set-Cookie: jsessionid=" + sessionId + "; path=/");


Session ID by kishida · Pull Request #8 · kishida/tinydi

セッション情報をインジェクト

最後に、セッションスコープで値をインジェクトできるようにします。

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface SessionScoped {
    
}


それから、セッションスコープのビーンを管理するクラスを作ります。名前はSessionから始めると補完がめんどいことになるので不自然な名前に・・・。まあちゃんとフレームワークとして独立させればどうにかなるんだろうけど。

public class BeanSession {
    private final ThreadLocal<String> sessionId = new InheritableThreadLocal<>();
    private final Map<String, Map<String, Object>> beans = new HashMap<>();
    
    public void setSessionId(String id) {
        sessionId.set(id);
        if (!beans.containsKey(id)) {
            beans.put(id, new HashMap<>());
        }
    }
    
    public Map<String, Object> getBeans() {
        return beans.get(sessionId.get());
    }

    public boolean isSessionRegistered(String id) {
        return beans.containsKey(id);
    }
    
}


で、Contextのほうで保持できるようにして。

    static BeanSession beanSession;
    
    public static void setBeanSession(BeanSession beanSession) {
        Context.beanSession = beanSession;
    }


@SessionScopedアノテーションがついていたらそっちを使うようにする。

    if (type.isAnnotationPresent(SessionScoped.class)) {
        scope = beanSession.getBeans();
    }


あとは、スコープの寿命の長さを比較できるようにして。

    private static int scopeRank(Class type) {
        if (type.isAnnotationPresent(RequestScoped.class)) {
            return 0;
        }
        if (type.isAnnotationPresent(SessionScoped.class)) {
            return 5;
        }
        return 10;
    }


寿命の長いオブジェクトに寿命の短いオブジェクトをインジェクトするときはラップするように変更。

            if (scopeRank(type) > scopeRank(field.getType())) {
                bean = scopeWrapper(field.getType(), field.getName());
            } else {
                bean = getBean(field.getName());
            }


ここまでできたら、Webサーバー側と結びつければいいだけです。
クッキーからセッションIDを取ってきて。

    String sessionId = cookies.get("jsessionid");


セッションIDがあったら、セッションとして管理しているかどうか確認して、なければリセット。

    if (sessionId != null) {
        if (!beanSession.isSessionRegistered(sessionId)) {
            sessionId = null;
        }
    }


セッションIDがなければ新しいIDを発行。

    if (sessionId == null) {
        sessionId = Long.toString(lastSessionId.incrementAndGet());
    }


そのセッションIDを登録しておきます。

    beanSession.setSessionId(sessionId);


そうすると、こういうセッションスコープのオブジェクトを用意して。

@Named
@SessionScoped
@Data
public class LoginSession {
    boolean logined;
    LocalDateTime loginTime;
}


こんな感じのコントローラを用意すると、ログインっぽいことができます。

@Named
@Path("login")
public class LoginController {
    
    @Inject
    LoginSession loginSession;
    
    @Path("index")
    public String index() {
        String title = "<h1>Login</h1>";
        if (loginSession.isLogined()) {
            return title + "Login at " + loginSession.getLoginTime();
        } else {
            return title + "Not Login";
        }
    }
    
    @Path("login")
    public String login() {
        loginSession.setLogined(true);
        loginSession.setLoginTime(LocalDateTime.now());
        return "<h1>Login</h1>login";
    }
    @Path("logout")
    public String logout() {
        loginSession.setLogined(false);
        return "<h1>Login</h1>logout";
    }
}


なんか、Webフレームワークみたいになりました!

その他やること

とはいえ、やることはいろいろあります。

  • スコープの処理を整理
  • HTTPに準拠
  • スレッドセーフに
  • 不正なアクセスに対処

その他いろいろ。
で、まあこういうのをいちいち手組みしていては実用的なものはできないので、WebサーバーにはJettyなりなんなり使って、使えるものは使っていったほうがいいですね。
ってやっていくと、まあSpring使いましょうってことになるわけですけど。

わかったこと

ベースとしてDIがあると、フレームワークを作るときに「汚いこと」をやる必要がなくなってよい。

Webを支える技術 -HTTP、URI、HTML、そしてREST (WEB+DB PRESS plus)

Webを支える技術 -HTTP、URI、HTML、そしてREST (WEB+DB PRESS plus)