☰
第 2 章 接口、回调和线程
2026/10/14 9:02:13 网站建设 项目流程

这一章要回答的问题

第 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

+start()

-handle(Socket)

«interface»

Endpoint

+init()

+service(String, String) : byte[]

+destroy()

«abstract»

HttpEndpoint

+service(String, String) : byte[]

#doGet(String) : byte[]

#doPost(String) : byte[]

HelloEndpoint

-int visits

#doGet(String) : byte[]

TimeEndpoint

#doGet(String) : byte[]

HttpRequest

+read(BufferedReader) : HttpRequest

+method() : String

+target() : String

HttpResponse

+write(OutputStream, int, byte[], boolean)

«java.net»

ServerSocket

+accept() : Socket

«java.lang»

Class

+forName(String) : Class

«java.util.concurrent»

ExecutorService

+submit(Runnable)

一次请求的调用顺序

开始监听之前,MiniContainer按表里的类名创建HelloEndpoint和TimeEndpoint,并各调用一次init。之后accept得到一条连接,把这条连接提交给线程池。池中的线程用HttpRequest读出方法和路径,按路径找到已经创建的实例,调用service。HttpEndpoint.service进入doGet。返回的字节由HttpResponse写回浏览器。

HttpResponseHelloEndpointHttpEndpointHttpRequestExecutorServiceMiniContainer浏览器HttpResponseHelloEndpointHttpEndpointHttpRequestExecutorServiceMiniContainer浏览器用户访问 /hello建立连接并发送 HTTP 请求submit(连接)read返回请求,target=/helloservice("GET", "/hello")doGet("/hello")返回正文write(状态码, 正文)HTTP 响应显示正文用户

访问/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=2

local是这一次调用开始时读到的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=3
endpoint=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 加载类、按路径调用,并完成现在这段监听。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询