这一章要回答的问题
第 1 章写出了一个处理 HTTP 请求的服务。RawHttpServer监听端口,HttpRequest解析报文,HttpResponse把方法和路径写回浏览器。
这一章要让不同的路径由不同的处理类来响应。/hello返回这个处理类被调用的次数,/time返回当前时间。处理类不监听端口,也不读取套接字。它只提供处理一次请求的方法,等请求到达之后由容器来调用。
容器如果在源码里写new HelloEndpoint(),每增加一个路径就要改容器,容器在编译期也会依赖每一个处理类。这里把「路径 → 类名」放进一张表,类名是字符串。容器用反射按字符串创建对象,创建代码只依赖接口Endpoint。
每个路径只创建一个实例,之后的请求都进入这同一个对象。线程池让两条连接可以同时进入它。实例字段在多次请求之间保留,两个线程同时写入时会互相覆盖。局部变量每次调用各有一份。
这一章仍不使用 Tomcat。监听、解析和写回沿用第 1 章,外面加上按路径找到处理类并调用它。
类关系
MiniContainer持有Endpoint。HttpEndpoint实现这个接口,service按方法名进入doGet或doPost。HelloEndpoint和TimeEndpoint各自覆盖doGet。HttpRequest与第 1 章相同。HttpResponse仍写出状态行、首部和正文,正文改由处理类返回。
ServerSocket来自java.net。Class来自java.lang。ExecutorService来自java.util.concurrent。
一次请求的调用顺序
开始监听之前,MiniContainer按表里的类名创建HelloEndpoint和TimeEndpoint,并各调用一次init。之后accept得到一条连接,把这条连接提交给线程池。池中的线程用HttpRequest读出方法和路径,按路径找到已经创建的实例,调用service。HttpEndpoint.service进入doGet。返回的字节由HttpResponse写回浏览器。
访问/time时,查到的是另一个实例TimeEndpoint,进入的是它的doGet。
运行示例
示例在codes/ch02-mini-container。本系列使用 JDK 17。在这个目录执行:
mvn -q package java -cp target/classes demo.http.MiniContainer标准输出先打印两次init,然后开始监听:
init demo.http.HelloEndpoint init demo.http.TimeEndpoint listening 8080用浏览器访问http://127.0.0.1:8080/hello。第一次响应正文是:
endpoint=demo.http.HelloEndpoint thread=pool-1-thread-1 local=0 visits=1再访问一次/hello:
endpoint=demo.http.HelloEndpoint thread=pool-1-thread-2 local=1 visits=2local是这一次调用开始时读到的visits。visits留在同一个对象上,所以第二次变成2。两次可以由线程池里不同的线程执行。
访问http://127.0.0.1:8080/time:
endpoint=demo.http.TimeEndpoint thread=pool-1-thread-1 time=2026-10-13T07:30:55.667004700Z这是另一个类的实例。它没有visits。time是这一次调用读取的时刻,再次访问会变。
表里没有的路径,例如/other,状态码是404,正文是no endpoint。
查看响应后终止进程。
为什么按类名创建
路径和处理类的对应写在main里。文件是codes/ch02-mini-container/src/main/java/demo/http/MiniContainer.java。
Map<String,String>routes=newLinkedHashMap<>();routes.put("/hello","demo.http.HelloEndpoint");routes.put("/time","demo.http.TimeEndpoint");表的值是类名字符串。start在监听之前为每一行创建对象:
for(Map.Entry<String,String>route:routes.entrySet()){endpoints.put(route.getKey(),create(route.getValue()));}create按字符串加载类,调用无参数构造器,再调用init。变量类型是Endpoint。
privatestaticEndpointcreate(StringclassName)throwsException{Class<?>type=Class.forName(className);Endpointendpoint=(Endpoint)type.getDeclaredConstructor().newInstance();endpoint.init();returnendpoint;}Class.forName在运行时按类名取得Class对象。getDeclaredConstructor取得无参数构造器,newInstance调用它。这种按名字创建对象的做法称为反射。
HelloEndpoint的源码里没有new自己。执行newInstance的是MiniContainer,返回的引用也由它放进endpoints。监听开始之后,请求只按路径取出这个已经创建的实例。所以连续访问/hello时,visits还在。
接口是一组方法声明。Endpoint声明了init、service、destroy。create和后面的调用都通过这个接口,不把变量声明成HelloEndpoint或TimeEndpoint。同一个接口变量在运行时进入不同实现类的方法,称为多态。/hello和/time因此可以共用查找和调用的代码。
再增加一个路径时,往表里加一行类名即可。create和handle不用改。
处理类的方法由容器调用
HelloEndpoint没有main,类里也没有调用doGet的代码。浏览器访问/hello时,MiniContainer读完报文,再调用service。处理类把方法写好、由容器在请求到达时调用,称为回调。
service写在父类HttpEndpoint里,而且是final。子类不能改写这段分发。父类按方法名进入doGet或doPost,子类只提供其中一步,称为模板方法。文件是codes/ch02-mini-container/src/main/java/demo/http/HttpEndpoint.java。
publicfinalbyte[]service(Stringmethod,Stringtarget){if("GET".equals(method)){returndoGet(target);}if("POST".equals(method)){returndoPost(target);}return("unsupported "+method+"\n").getBytes(StandardCharsets.UTF_8);}这一章用浏览器访问,方法是GET,会进入doGet。method和路径来自HttpRequest解析的报文,不再由容器写成固定的字符串。
HelloEndpoint.doGet生成正文。visits是实例字段。seen和local是这一次调用的局部变量。文件是codes/ch02-mini-container/src/main/java/demo/http/HelloEndpoint.java。
protectedbyte[]doGet(Stringtarget){intseen=visits;intlocal=seen;overlap();visits=seen+1;Stringtext="endpoint="+getClass().getName()+"\n"+"thread="+Thread.currentThread().getName()+"\n"+"local="+local+"\n"+"visits="+visits+"\n";returntext.getBytes(StandardCharsets.UTF_8);}同一个实例上的两个线程
第 1 章里,accept返回之后,当前线程要处理完这条连接,才会再次accept。这一章把连接交给线程池,接受线程回到accept,可以继续接收下一条连接。池中有两个线程。
while(true){Socketsocket=server.accept();pool.submit(()->handle(socket));}两条连接可以同时进入同一个HelloEndpoint。visits只有一份。overlap在读出seen之后、写回visits之前暂停 200 毫秒,让另一个线程能在这两条语句之间也读到同一个值。后执行visits = seen + 1的写入覆盖先写入的值,其中一次加一没有留下。
在visits已经是2时,几乎同时发出两个对/hello的请求,两份响应是:
endpoint=demo.http.HelloEndpoint thread=pool-1-thread-1 local=2 visits=3endpoint=demo.http.HelloEndpoint thread=pool-1-thread-2 local=2 visits=3两次的local都是2,说明两个线程读到了同一份字段。若两次加一都留下,visits应从2变成4。两份响应都是3,有一次写入被覆盖了。
局部变量不共享。每个线程的seen和local在自己的调用里,另一个线程改的是字段visits,改不到对方的局部变量。
进程结束时,MiniContainer对每个已创建的实例调用destroy。HelloEndpoint在这里打印最后的visits。init在监听之前每个实例一次,destroy在结束时每个实例一次,请求反复进入的是它们中间的service。
这些事现在由谁做
监听端口、解析报文、按路径查表、用反射创建处理类、用线程池调用service,现在都在MiniContainer。处理类只生成正文。
后面的 Tomcat 会接过监听、查表、创建和调用。处理类仍然只写自己的doGet。路径到类名的这张表,也会从main挪到配置里。
接口、反射和这种回调,后面用来说明一个对象怎样由容器创建并调用。这一章只说明调用是怎样发生的。
结论
- 不同路径由不同的处理类响应。容器按表里的类名用反射创建它们,再通过
Endpoint调用init、service、destroy。 - 处理类不调用这些方法。请求到达后,由容器调用。
HttpEndpoint.service决定进入doGet还是doPost。子类改写的是生成正文的方法。- 同一个路径对应同一个实例。实例字段在多次请求之间保留,两个线程同时写入时会互相覆盖。局部变量每次调用各有一份。
下一章要处理的问题
路径到类名的表、ServerSocket和线程池还写在MiniContainer里。下一章换成 Servlet 规定的接口,由 Tomcat 加载类、按路径调用,并完成现在这段监听。