框架:Spring 形状的容器与 web 层
依赖注入与请求绑定都在编译期完成,而不是像 Spring 那样在启动时用反射扫描。
lib/17_container.teyru、lib/18_web.teyru 加上 internal/sema/framework*.go 构成
一个应用程序框架,接口照 Spring 抄,实现方式不同。
为什么不是反射
Spring 在启动时扫描 classpath、读注解、用反射创建与注入 bean、把请求绑定到方法。
这里的容器也读注解、也用反射创建与注入(lib/28_container_reflect.teyru)——
差别在 bean 清单:没有 classpath 可以扫,所以清单是编译器列出来的,其余每一件事
都是启动时从类本身读出来的。
两边的差别是具体的:
| Spring | Teyru | |
|---|---|---|
| bean 从哪来 | 运行时扫描 classpath | 编译器列出带 @Component 等标注的类(没有 classpath 可扫) |
| 读注解与注入 | 运行时反射 | 运行时反射,读的是类本身 |
| 缺少 bean | NoSuchBeanDefinitionException,启动时 | IllegalStateException,refresh() 时 |
| 循环依赖 | BeanCurrentlyInCreationException,启动时 | IllegalStateException,refresh() 时,消息里有环 |
| 运行时新增 bean | 可以 | 不行(清单来自编译器) |
| 启动成本 | 扫描与反射 | 反射,没有扫描 |
代价是明确的:不能在运行期扩展。好处也是:整类错误从“部署之后才看到”变成“编译 不过”。
容器
import teyru.*
@Repository
class UserRepo {
String find(String id) { return "user " + id }
}
@Service
class UserService {
@Autowired UserRepo repo
@Value("${app.name:unnamed}") String appName
private boolean ready = false
@PostConstruct
void start() { ready = true }
String describe() { return appName + ": " + repo.find("7") }
}
class Main {
public static void main(String[] args) {
ApplicationContext ctx = new ApplicationContext()
ctx.setProperty("app.name", "demo")
ctx.refresh()
UserService s = (UserService) ctx.getBean(UserService.class)
System.out.println(s.describe())
}
}支持的注解
| 注解 | 含义 |
|---|---|
@Component/@Service/@Repository/@Controller/@RestController | 这个类是 bean;名称默认是首字母小写的类名,@Component("x") 可改 |
@Configuration + @Bean | 这个类本身也是 bean,它的 @Bean 方法各产生一个 bean |
@Autowired | 字段、构造器参数或 @Bean 方法参数由容器提供 |
@Qualifier("name") | 同类型有多个时指名 |
@Primary | 同类型有多个时的默认选择 |
@Value("${key}")/@Value("${key:default}") | 注入属性 |
@PostConstruct | 构建并注入完成后调用(无参、返回 void) |
@Scope("prototype") | 每次查询都创建新的;默认 singleton |
构造器选择
Spring 4.3 起的规则:只有一个构造器就用它,否则找标了 @Autowired 的,否则用无参
的那个。被选上的构造器,每个参数都要自己标 @Autowired(或 @Value)才会被注入:
没标的参数不会被填充,生成的工厂就用少掉的实参调用它,编译以 TY-TYP-0072 失败。
标了多个 @Autowired,或都没有而有多个又没有无参构造器时,是 TY-TYP-0105。
生命周期
refresh() 会把每个 singleton 都创建起来,所以构造与注入的失败会在启动时(而不是
第一次请求时)报出来——缺 bean、同一类型有两个候选、依赖成环都是这个时候才失败,
不是编译期:@Service class Needs { Needs(NoAnno other) } 这种写法编得过,refresh() 时
以 parameter 0 of Needs is neither @Value nor @Autowired 停下来(Spring 也是启动时才发现,
所以 TY-TYP-0103 已经移除)。bean 的创建是递归的:要 A 就先创建它需要的 B,
creating 标志位挡住环。
@PostConstruct 由生成的注入器在注入完成后调用。@PreDestroy 已声明但没有
执行——Teyru 没有进程关闭钩子,容器也没有 close()。
Web 层
import teyru.*
@RestController
@RequestMapping("/pets")
class PetController {
@Autowired PetStore store
@GetMapping("/{id}")
Pet one(@PathVariable("id") int id) { return store.find(id) }
@GetMapping("")
String list(@RequestParam(value = "q", defaultValue = "all") String q) {
return "q=" + q
}
@PostMapping("")
Pet create(@RequestBody String body) { return new Pet(body, 1) }
}| 注解 | 含义 |
|---|---|
@RequestMapping(类上) | 路径前缀 |
@RequestMapping(方法上) | 路径与 method(不写是任何动词) |
@GetMapping/@PostMapping/@PutMapping/@DeleteMapping/@PatchMapping | 路径与动词 |
@PathVariable | 路径里 {name} 的值,会转换成参数的类型 |
@RequestParam | 查询参数,defaultValue 可给默认值 |
@RequestHeader | 请求头(名称不区分大小写);defaultValue 是没有这个请求头时的值,不给就是空字符串 |
@RequestBody | 请求体;参数是 String 就原样拿到,是类(或 record)就以 Gson 绑定解析 |
@ResponseBody | 已声明;@RestController 本来就隐含,所以有没有都一样 |
参数转换:路径与查询参数都是字符串,所以 int、long、double、float、
short、byte、boolean 的参数会经由对应的 parseX 转换(int 用
Integer.parseInt)。
请求体:@RequestBody 的参数是 String 时拿到原样的请求体(想自己查看的
payload 就是这样接),是类或 record 时由编译器为该类型生成的 Gson 绑定解析
——Spring 依 Content-Type 挑选消息转换器,这里类型在编译期就知道了,转换器就是那
个绑定。解析失败抛出的是同一个 JsonSyntaxException。没有 JSON 映射的类型是编译
错误,不是第一次请求时的异常。
响应:返回 HttpResponse 就完全自己决定;返回 String 是 text/plain;返回
void 是空主体;其余一律由 Gson 绑定写成 application/json(见 docs/json.md)
——record、enum(写成常量名称的字符串)、数组、List、Map 都一样。原本“基本类型
没有映射”的 TY-TYP-0111 已经移除,绑定现在收得下所有类型。
处理函数的参数:类型是 HttpRequest 的参数就是这个请求本身(名字不拘),
Spring 把 HttpServletRequest 交给处理函数也是这样;其余没有标注的参数照旧当成
同名查询参数。
路由:Router.match 取最具体的匹配——字面片段胜过变量片段,所以
/pets/mine 不会被 /pets/{id} 吃掉,与注册顺序无关。路径存在但动词不对是 405,
路径不存在是 404。
服务器
ApplicationContext ctx = Application.boot(args)
Router router = Application.routerFrom(ctx)
HttpServer server = new HttpServer(port, router, ctx)HttpServer.handle(HttpRequest) 是请求进来后唯一的入口——它与 socket 循环分开,
所以不需要连接就能测试:tests/programs/t102_web.teyru 与
tests/programs/t141_web_param_errors.teyru 就是这样测试的,后者覆盖转换失败的 400、
enum 参数与返回值、defaultValue。
TLS。 server.ssl(certificate, privateKey) 收的是 PEM 证书链与其私钥的路径
(就是 Spring 的 server.ssl.certificate 与 server.ssl.certificate-private-key),
给了之后这个服务器接到的每一个连接都是 TLS,server.isSecure() 回答它是不是。那组
文件在配置时就读取与检查,所以用不了的证书在这里失败——程序还说得出哪里错——而不是
让每一个进来的客户端失败。自己建 HttpServer 的程序(下面〈服务器跑在自己的线程
上〉那一节、以及 Application.boot + routerFrom 那条路)就是自己调用它;
Application.serve(ctx, port, idleMs) 会读上面那两个属性,有
server.ssl.certificate 时自己调用 ssl()——SpringApplication.run 不会。
这一层是 OpenSSL,所以只有 POSIX 的目标有;windows 与 macOS 是具名拒绝,而且在写出
任何输出文件之前(见 docs/native.md),不碰 TLS 的程序则不会被
链接 OpenSSL。W9 之后「碰得到」算的是程序自己的调用图(反射的成员表不算),所以一个
不调用 ssl() 的 web 程序在没有 OpenSSL 头文件的机器上也建得起来并照常服务——实测
是把 cc 换成一个假装没有 openssl/err.h 的包装之后,web.teyru 构建成功,GET /ok
得到 fine [200]。服务器与客户端在同一个程序里往返的测试是
tests/programs/t163_https_roundtrip.teyru,一次 TLS 连接上的两个请求与握手超时是
t191_tls_keepalive.teyru 与 t192_tls_handshake_timeout.teyru。
处理函数自己坏掉不会带走服务器。 递归过深(见 docs/index.md 的〈运行时
模型〉)在处理函数里是该请求得到 500,服务器继续服务下一个请求,服务线程还活着——
tests/programs/t241_http_stack_overflow.teyru。
并发模型
服务器有一条线程负责 accept,另一串线程负责回应。accept() 只属于接收线程,别的都
不做:连接(含 TLS 握手)交给工作线程之后,接收线程在对方还没送出第一个字节之前就回到
accept() 了。一个慢的、沉默的、或话没说完的客户端占住的是一个工作线程,listener 照样
收下一个连接。
工作者是 cached 形状(lib/37_executor.teyru):闲下来一秒就结束,工作来了而没有
闲置的就再开一条。这是运行时的性质,不是调参选择——这里没有 daemon 线程,所以固定大小
的池子会让「服务一个连接就从 main 回来的程序」永远结束不了。
并发有三个上限,三个都是客户端碰得到的数字:
| 什么 | 默认 | 超过时 |
|---|---|---|
| 同时回应连接的工作线程 | 64 | 连接在队列里等 |
| 等工作的工作线程的连接 | 100 | 接收线程回 503(带 Retry-After: 1)然后关闭 |
| WebSocket 会话 | 256 | 在 101 之前回 503,连接关闭 |
WebSocket 不由请求的池子服务:会话交给它自己的线程(SocketSessionTask),所以一条开
一下午的会话占的是一个会话的额度,不是一个回应请求的工作者。
stop()(以及调用它的 close())停止接收与收件,把「还没开始读请求」的连接直接结束
而不是陪它等,给正在处理的请求 2 秒(SHUTDOWN_GRACE_MS)完成,然后关掉剩下的。
工作者在 0.25 秒(READ_POLL_MS)内就会察觉,因为每一个等待都是一次至多那么长的
轮询;同一个上限也是「卡在 recv() 的工作者不会拖住停止世界收集」的原因。
这一节的行为有测试:tests/programs/t223_http_concurrency.teyru(一条连接每 2 秒送
1 个字节,期间另一个客户端连 20 个 GET 都在 1 秒内完成;100 个并发客户端各发 10 个
keep-alive 请求全部 200;2 个工作线程/1 个队列的服务器回 503)与
tests/programs/t225_http_serve_loop.teyru(serve() 会回应、close() 会结束它、
线程会结束)。
客户端的限制
从配置读出来,默认值如下;属性的名字就是键,读它们的地方是
Application.configureLimits(Application.configureServer 的最后一步):
| 属性 | 默认 | 意思 | 拒绝 |
|---|---|---|---|
server.teyru.max-request-line-bytes | 8192 | 请求行,以字节计 | 414 |
server.max-http-request-header-size | 8192 | 整个头块,以字节计 | 431 |
server.teyru.max-header-count | 100 | 头(与 trailer)个数 | 431 |
server.teyru.max-body-bytes | 10485760 | 请求体,以字节计 | 413 |
server.teyru.header-timeout-ms | 10000 | 头,从第一个字节到空行 | 408 |
server.teyru.body-timeout-ms | 30000 | 体,整体 | 408 |
server.teyru.keep-alive-idle-ms | 15000 | 两个请求之间的连接 | 连接关闭 |
server.teyru.max-requests-per-connection | 100 | 一条连接上的请求数 | 响应带 Connection: close |
server.teyru.worker-threads | 64 | 同时回应的连接数 | 503 |
server.teyru.accept-queue | 100 | 等工作的连接数 | 503 |
server.teyru.websocket.max-connections | 256 | 打开的会话 | 101 之前回 503 |
server.teyru.websocket.max-message-bytes | 1048576 | 一条消息,含分片 | 1009 |
server.teyru.websocket.idle-timeout-ms | 60000 | 什么都不说的会话 | 1001 |
server.max-http-request-header-size 是 Spring Boot 自己的键,其余在 server.teyru.*
底下,形状照 server.tomcat.*。握手是例外:它有自己的上限,而且没有键——TLS 握手固定
10 秒(handshakeTimeoutMs,s.setSoTimeout 用它),与 header-timeout-ms 是同一个时钟上
的两个常数,所以调那两个属性不会动到握手。不是数字的值等于用默认值,而不是拒绝启动:默认值在构造
上就是安全的数字,而一个因为打错一个上限就起不来的服务器,是没人在手机上修得好的服务器。
每一个上限都在字节到达时检查,不是等收完才检查,所以拒绝发生在成本之前:一个
Content-Length: 1000000000 的请求在 1 秒内得到 413,而进程的常驻集不会因为客户端喊了
一个数字就长大 1 GB(RSS 增长小于 16 MB,在 Linux 上读 /proc/self/status 量到的)。
请求体读进的缓冲区跟着实际到达的数据长大。
截止时间是总时长,不是单次读取的超时。每一次读取拿到的是「剩余截止时间」与 0.25 秒 之中较小的一个,所以每 2 秒送 1 个字节的客户端在截止时间到达时被拒绝,而不是每一个字节 都换到一次新的超时。
限制的测试是 tests/programs/t221_http_limits.teyru;字节主体(Content-Length 是
UTF-8 的字节数、256 个字节值的 multipart 逐字节往返)是
tests/programs/t220_http_body_bytes.teyru。
解析器拒绝什么(RFC 9112)
Content-Length 与 Transfer-Encoding 同时出现(400)、Content-Length 声明两次而值
不同、不是数字、负数、或大到放不下(400)、Transfer-Encoding 的最后一个编码不是
chunked(400)、或指名一个这里没有实现的编码(501)、头名与冒号之间有空白(400)、
延续上一行的头(obs-fold,400)、头名不是 token(400)、HTTP 版本不是 1.0 或 1.1
(505)、chunk 大小不是十六进制或溢出(400)、chunk 超过请求体上限(413),以及
Expect 指名 100-continue 以外的东西(417)。absolute-form 的请求目标按它的路径路由。
被拒绝的请求会关闭连接:它声明的字节可能还在路上,而一个只有客户端知道读到哪里的流, 不是下一个请求可以接上去读的流。
而这件事有按行为验的测试,不是只有清单:tests/programs/t222_http_parser_fuzz.teyru
(4000 个变异过的请求,0 次崩溃)与 tests/programs/t221_http_limits.teyru 的那张表。
启动与配置
class Main {
public static void main(String[] args) {
SpringApplication.run(Main.class, args)
}
}SpringApplication.run 等同于 new ApplicationContext() +
SpringApplication.loadConfig(ctx, args) + ctx.refresh(),配置来源依次是:
工作目录的 application.properties(--spring.config.name=路径 可以换一份),
再叠上 --key=value 形式的命令行参数。读回来用 ctx.getProperty("app.name") 与
ctx.getProperty("app.name", "默认"),问有没有用 ctx.hasProperty(...)。
@ConfigurationProperties(prefix = "app") 标在 bean 上,app.* 就会绑进它的字段,
键名比对是宽松的:app.max-size 绑 maxSize、app.max_size 也绑。@Profile("prod")
的 bean 只在该 profile 生效(--spring.profiles.active=prod)。@PreDestroy 的方法
在 ctx.close() 时执行。
跨请求的关注点
| 东西 | 怎么声明 | 行为 |
|---|---|---|
@ControllerAdvice + @ExceptionHandler(X.class) | 类上的 advice、方法上的处理 | controller 抛出的 X(含子类)由这个方法回答;返回值就是响应,可以是 HttpResponse、ResponseEntity 或主体 |
HandlerInterceptor | 实现接口的 bean | 每个请求都会 preHandle,回 false 就是 403(由 interceptor 决定内容);afterCompletion 在响应送出前跑 |
| 静态文件 | spring.web.static=目录 | 没有路由认领的路径就找该目录里的文件,Content-Type 依扩展名;路径含 .. 一律 404 |
| CORS | spring.web.cors=来源,来源 | 预检由服务器直接回答,不进 controller;标头盖在最后真正送出的响应上 |
反射调用会把异常包成 InvocationTargetException,但 advice 看到的是 controller
真正想抛的那个:这层包装在比对之前先拆掉。
会话
@GetMapping("/cart")
String cart(HttpRequest req) {
HttpSession s = Sessions.of(req)
Object n = s.getAttribute("count")
int v = n == null ? 0 : ((Integer) n).intValue()
s.setAttribute("count", Integer.valueOf(v + 1))
return "count=" + s.getString("count")
}Sessions.of(req) 找出这个请求的 cookie 指到的会话,没有就建一个,并在服务器要
送出的响应上补 Set-Cookie(TEYRUSSESSIONID,带 HttpOnly)。这里是唯一能盖
cookie 的地方:路由的响应是处理函数返回后才产生的,这也是为什么处理函数拿到的是
请求而不是注入的响应。
isNew() 只在建立它的那个请求为 true,下一个请求起就是 false;find(req) 只找不建
(登录页读既有会话就是这个);invalidate() 把 id 从 store 拿掉,客户端手上的旧
cookie 就指不到东西,下一个请求会拿到新的会话;attributeNames() 保持首次设定的
顺序;Sessions.count()/clear() 是给测试用的。
超时。 setMaxInactiveInterval(秒) 给单个会话一个空闲上限,Sessions.setTimeout(秒)
给整个 store 一个默认;0(或负数)是没有限制,会话自己设了就以自己的为准,否则
用 store 的默认。配置文件写 Boot 的 server.servlet.session.timeout,值是 30m、2h、
1d、45s 这种长度或直接写秒数;spring.web.session.timeout 是同一件事的短写法,
Boot 的那个键优先。读不出来的值当成 0(没有限制)而不是猜一个数字。
超时的会话等于不存在:find(req) 把它从 store 拿掉并回应 null,客户端手上的 cookie
就指不到东西,下一个想要会话的请求会拿到新的(tests/programs/t181_session_timeout.teyru)。
刻意没有后台清理线程。 这个运行期在进程结束时会 join 每一条线程,一条无限循环
的清理线程会让程序结束不了;改成每个请求顺手检查几个会话(游标前进,一次八个),
所以成本摊在流量上而不是交给一条 reaper;Sessions.prune() 一次扫完整个 store,给
测试或空闲的时候用。要注意的是:没有设 store 默认值时那个游标不做事——只有自己设
了区间的会话,是靠 find() 或 prune() 才会被发现。
验证
lib/36_validation.teyru 是 Bean Validation 的那一小块:类在自己的字段上声明约束,
一个调用检查它们。
| 注解 | 意思 |
|---|---|
@NotNull | 字段不能是 null |
@Size(min = …, max = …) | String 字段的长度落在 [min, max] 内,两端都含 |
@Min(value = …)/@Max(value = …) | 数值字段的下界/上界 |
四个都有 String message() default …,没写就用默认消息。Validation.check(bean) 读
对象自己声明的字段(走的是 JSON 绑定也在读的那套反射),碰到第一个违反约束的字段
就抛 ValidationException,消息是 字段: 消息——Spring 会一次报完所有违反,这里只报
第一个,要看其余的可以再问一次。
web 层对绑定产生的每一个对象都调用它,所以请求体违反自己类型的约束时,响应是
400,主体是 bad request: 字段: 消息:客户端做错的事,说得出是哪个字段。@Min/
@Max 标在非数值字段、@Size 标在非 String 字段时也是 violation,消息说明那个约束
读不了这个字段——那是类写错了,但不该让服务器崩溃。tests/programs/t160_validation.teyru
覆盖了这些。
这四个名字是被占走的。 Teyru 的简单名称在同一个平坦命名空间里,所以 NotNull、
Size、Min、Max 已经是约束注解的名字,程序不能拿它们当自己类型的名字:自己声明
一个 class Size 之后,@Size 就指向那个类,约束不再被检查(安静地不检查)。
上传(multipart)
HttpRequest.multipart(String name) 回应 multipart/form-data 请求里以该字段名送出的
那个部分,类型是 MultipartFile:name、originalFilename、contentType、content
(请求体本来就是字符串,所以文件的内容是以送出的那些字符到达的),加上 isEmpty() 与
size();没有这个字段时是 null。
不是 multipart、Content-Type 没有 boundary、或主体不是它声明的那个 multipart 时,
答案也是 null,不是异常——要不要回 400 是处理函数的决定。urlencoded 表单不受影响,
照旧由 @RequestParam 绑定(见 tests/programs/t161_multipart.teyru)。
压缩的响应
响应要不要压缩是处理函数说了算:把 HttpResponse.gzipBody 设成 true,连接循环在
送出前读 Accept-Encoding,只有下面每一项都成立才真的压:
- 请求的
Accept-Encoding收 gzip(gzip、*;gzip;q=0是拒绝,不是接受); - 主体至少 1024 字节——gzip 自己的头与尾就 18 字节,已经是一个包的主体不会因为多一个头 而更好;
- 压完真的比较短。
压缩的来源是 lib/44_zip.teyru(见 docs/language.md §11),
Content-Encoding: gzip 只在真的压了才写;只要处理函数要过压缩,响应就会带
Vary: Accept-Encoding,无论这一个请求最后有没有压——缓存不能把压过的答案交给
一个没说自己能解压的客户端。Content-Length 由服务器在送出时依(压完的)主体重算。
客户端那一半是 opt-in:HttpClientRequest.acceptGzip() 才会送 Accept-Encoding: gzip,
而答案写了 Content-Encoding: gzip 时客户端会自己解开,头(含 Content-Length)
保持连线上原样的数字。压坏的 gzip 不会被吞掉:ZipException/EOFException 会从
send 传出来(tests/programs/t180_http_gzip.teyru)。
测试
MockServer 不开 socket,直接问 HttpServer.handle:
MockServer server = new MockServer(ctx)
server.get("/pets") // 主体
server.request("POST", "/pets", body, "application/json")
server.handle(req) // 自己组的请求,看标头理由与 Spring 的 MockMvc 相同:测路由不该需要一个端口、一个客户端或第二条线程。
server.handle(req) 收的是已经准备好的请求,cookie 要自己 readCookies()。
服务器跑在自己的线程上
接收循环也做成了 Runnable(lib/18_web.teyru 的 ServerTask),所以客户端与服务器
可以活在同一个程序里:
HttpServer server = new HttpServer(0, Application.routerFrom(ctx), ctx)
server.bind() // 先绑,端口才是已知的
ServerTask task = new ServerTask(server)
Thread serving = new Thread(task, "server")
serving.start()
// …发请求…
task.stop()
serving.join()server.bind() 要在启动线程之前做:端口(server.getPort())是拼 URL 要用的,而已经
绑好的 listener 也答得掉在线程走到 accept 之前到达的连接。stop() 停止接收,结束还没
开始读请求的连接(不陪它等),给进行中的请求 2 秒完成,然后关掉剩下的,isRunning()
回应它还在不在跑。端口传 0
是请内核挑一个空闲端口,tests/programs/t162_http_roundtrip.teyru 就是让服务器跑在一条
线程上、主线程当客户端,在同一个程序里往返。
已知限制
- 连接数是有限的,满了就回 503。 同时回应的连接默认 64 条,等的默认 100 条,超过 就由接收线程直接回 503 并关闭(上面的〈并发模型〉)。这是刻意的界线:没有上限的 thread-per-connection 会让一个客户端决定这个进程要用多少线程。要服务更多连接就调高 那两个数字,代价是内存与调度。
- 没有 HTTP/2,也没有 HTTP/3。 版本不是 1.0 或 1.1 一律 505;没有 TLS 之上的 ALPN 协商,所以 HTTPS 也只讲 HTTP/1.1。
- 几乎没有内容协商。 只看方法的声明类型,不看
Accept;唯一的例外是Accept-Encoding与 gzip——而且那要处理函数先把gzipBody打开。brotli、deflate 等其他编码没有。 - 会话活在进程里。 两个进程后面接同一个服务时,请求要回到产生会话的那一个;
会话 id 是
java.util.Random的 128 位,对单一服务器够用,不是密码学来源。 - 没有 SSE。 multipart 上传与验证注解都有了,见上面两节;服务器推送没有。
- 没有
@Conditional、@Import、@Lazy、AOP、事务,也没有@ComponentScan的范围控制:整个程序都是扫描范围,因为编译器看得见全部——要 排除什么,就不要标注它。 SpringApplication.run不读server.ssl.certificate。 读那两个属性的是Application.serve,它自己建服务器;SpringApplication.run也自己建一个,但 没有那个分支。要用这个入口跑 HTTPS,得自己建HttpServer并调用ssl(cert, key)再自己服务,或改用Application.serve。