Oxpecker 0.13.1
dotnet add package Oxpecker --version 0.13.1
NuGet\Install-Package Oxpecker -Version 0.13.1
<PackageReference Include="Oxpecker" Version="0.13.1" />
paket add Oxpecker --version 0.13.1
#r "nuget: Oxpecker, 0.13.1"
// Install Oxpecker as a Cake Addin #addin nuget:?package=Oxpecker&version=0.13.1 // Install Oxpecker as a Cake Tool #tool nuget:?package=Oxpecker&version=0.13.1
Oxpecker
Oxpecker 是一个基于 ASP.NET Core 端点路由的 F# 框架(类似于 Minimal APIs,因此它们是竞争对手),具有易于理解的 API,主要继承了 Giraffe 框架。
Nuget 包 dotnet add package Oxpecker
示例可以在这里找到 这里
性能测试位于 这里
文档
对 Oxpecker 所有功能的深入功能参考。
目录
基本概念
核心概念
Oxpecker 是基于 ASP.NET Core 端点路由 构建而成,并为 F# 用户提供了方便的声明式语言。
使用 Oxpecker 时,请确保您熟悉 ASP.NET Core 以及其概念,因为 Oxpecker 会重用很多内置功能。
EndpointHandler
Oxpecker 中的主要构建块是 EndpointHandler
type EndpointHandler = HttpContext -> Task
EndpointHandler
是一个函数,它接受HttpContext
,并在完成后返回一个Task
。
EndpointHandler
函数完全控制传入的HttpRequest
和生成的HttpResponse
。它紧密遵循RequestDelegate签名,但采用F#风格。
EndpointHandler
通常应被视为一个终端处理器,这意味着它应该向响应写入一些结果(但不一定,如组合部分所述)。
EndpointMiddleware
type EndpointMiddleware = EndpointHandler -> HttpContext -> Task
EndpointMiddleware
与EndpointHandler
类似,但接受第一个参数为next EndpointHandler
。
每个EndpointMiddleware
都可以在通过调用下一个EndpointMiddleware
继续向下传递到Oxpecker管道之前处理传入的HttpRequest
,或者通过返回自己的Task
来提前中断执行。
EndpointHandler 与 EndpointMiddleware 的对比
那么,何时你应该定义一个或另一个呢?答案在于处理器的职责
- 如果你想要有条件地返回响应或进一步在管道中执行,请使用
EndpointMiddleware
。示例是认证端点中间件和预条件端点中间件。 - 如果你想在下一个处理器完成后执行某些逻辑,请使用
EndpointMiddleware
- 其他情况下请使用
EndpointHandler
Oxpecker 管道与 ASP.NET Core 管道的比较
Oxpecker 管道是与 ASP.NET Core 管道(面向对象)的(类似)函数等价物。ASP.NET Core 管道由中间件定义,而EndpointMiddleware
类似于常规中间件,EndpointHandler
类似于终端中间件。
如果 Oxpecker 管道没有处理传入的HttpRequest
(因为没有匹配路由),则其他 ASP.NET Core 中间件仍然可以处理请求(例如,静态文件中间件或在 Oxpecker 之后插入的另一个 Web 框架)。
这种架构允许 F# 开发者通过函数组合构建丰富的前端应用程序EndpointMiddleware
和EndpointHandler
函数,同时通过使用现有的 ASP.NET Core 中间件而从更广泛的 ASP.NET Core 生态系统受益。
Oxpecker 管道通过OxpeckerMiddleware
本身集成到更广泛的 ASP.NET Core 管道中,因此是对它的补充而不是替代。
创建新的 EndpointHandler 和 EndpointMiddleware 的方法
有几种方法可以在 Oxpecker 中创建一个新的EndpointHandler
。
最简单的方法是重用现有的EndpointHandler
函数
let sayHelloWorld : EndpointHandler = text "Hello World, from Oxpecker"
你还可以在返回现有的EndpointHandler
函数之前添加附加参数
let sayHelloWorld (name: string) : EndpointHandler =
let greeting = sprintf "Hello World, from %s" name
text greeting
如果你需要访问HttpContext
对象,你必须显式地返回一个接受HttpContext
对象并返回Task
的EndpointHandler
函数
let sayHelloWorld : EndpointHandler =
fun (ctx: HttpContext) ->
let name =
ctx.TryGetQueryValue "name"
|> Option.defaultValue "Oxpecker"
let greeting = sprintf "Hello World, from %s" name
text greeting ctx
定义新EndpointHandler
函数的最啰嗦版本是显式返回一个Task
。在EndpointHandler
函数内调用异步操作时非常有用。
type Person = { Name : string }
let sayHelloWorld : EndpointHandler =
fun (ctx: HttpContext) ->
task {
let! person = ctx.BindJson<Person>()
let greeting = sprintf "Hello World, from %s" person.Name
return! text greeting ctx
}
EndpointMiddleware
的构建方式与EndpointHandler
非常相似,但它接受一个额外的EndpointHandler
作为第一个参数
let tryCatchMW : EndpointMiddleware =
fun (next: EndpointHandler) (ctx: HttpContext) ->
task {
try
return! next ctx
with
| ex ->
ctx.Response.StatusCode <- 500
return! text (sprintf "An error occurred: %s" ex.Message) ctx
}
推迟 Task 的执行
请务必注意,在.NET中,一个Task<'T>
表示任务最终异步完成时对类型EndpointHandler
函数(带有task {}
CE)并显式使用let!
或return!
等待嵌套结果,否则处理程序不会在返回到OxpeckerMiddleware
之前等待任务完成。
如果您想在任务返回后执行EndpointHandler
中的代码,如使用use
关键字清理资源,这具有重要的意义。例如,下面代码中的IDisposable
将在返回实际响应之前被释放。这是因为EndpointHandler
是HttpContext -> Task
类型,因此代码text "Hello" ctx
只返回了一个尚未完成的Task
。
let doSomething : EndpointHandler =
fun ctx ->
use __ = somethingToBeDisposedAtTheEndOfTheRequest
text "Hello" ctx
但是,通过在task {}
CE中显式调用text
,可以确保在释放IDisposable
之前执行text
。
let doSomething : EndpointHandler =
fun (ctx: HttpContext) ->
task {
use __ = somethingToBeDisposedAtTheEndOfTheRequest
return! text "Hello" ctx
}
组合
处理程序组合
鱼形操作符(>=>)将两个函数组合成一个。
它可以组合
EndpointMiddleware
和EndpointMiddleware
EndpointMiddleware
和EndpointHandler
EndpointHandler
和EndpointHandler
这是Oxpecker中的一个重要组合器,它允许将许多较小的函数组合成一个大型的Web应用程序
鱼形操作符可以连接的函数数量没有任何限制
let app =
route "/" (
setHttpHeader "X-Foo" "Bar"
>=> setStatusCode 200
>=> text "Hello World"
)
每个函数都可以决定:短路管道或继续。对于EndpointMiddleware
,它是选择调用next或不调用,对于EndpointHandler
,它是开始编写响应或不编写。
如果您想了解更多有关>=>
(鱼形)操作符的起源,请参阅Scott Wlaschin的技术博客文章关于铁路导向编程。
routef
函数不能直接与鱼形操作符一起使用,因此添加了额外的操作符以使路由可读性更高
routef "/{%s}" (setStatusCode 200 >>=> handler)
routef "/{%s}/{%s}" (setStatusCode 200 >>=>+ handler)
routef "/{%s}/{%s}/{%s}" (setStatusCode 200 >>=>++ handler)
绑定组合
bindQuery
、bindForm
和bindJson
辅助函数可以被处理程序组合
route "/test" (bindQuery handler)
routef
在与bind*函数组合时也需要额外的操作符
routef "/{%s}" (bindQuery << handler)
routef "/{%s}/{%s}" (bindForm <<+ handler)
routef "/{%s}/{%s}/{%s}" (bindJson <<++ handler)
多路由处理程序
有时,您希望使用一些通用的处理程序或中间件不仅仅是与一个路由一起使用,而是与整个路由集合一起使用。这可以通过使用applyBefore
和applyAfter
函数来实现。例如
let MY_HEADER = applyBefore (setHttpHeader "my" "header")
let webApp = [
MY_HEADER <| subRoute "/auth" [
route "/open" handler1
route "/closed" handler2
]
]
Continue 与 Return
在Oxpecker中,特定的EndpointMiddleware
或EndpointHandler
可以使用两种场景
- 与下一个处理程序继续
- 提前返回
继续
例如,一个假设的中间件,它在设置给定的HTTP头之后,总是调用到next
http处理器
let setHttpHeader key value : EndpointMiddleware =
fun (next: HttpFunc) (ctx: HttpContext) ->
ctx.SetHttpHeader key value
next ctx
中间件对HttpRequest
和/或HttpResponse
对象执行某些操作,然后调用next
处理器以**继续**执行管道。
它也可以实现为EndpointHandler
let setHttpHeader key value : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.SetHttpHeader key value
Task.CompletedTask
如果这种处理程序在管道**中间**被使用,下一个处理程序将被调用,因为ctx.Response.HasStarted
将返回false。如果它在管道**末尾**,则响应将开始,因为没有下一个处理器被调用。
提前返回
有时,EndpointHandler
或EndpointMiddleware
想要提前返回,而不是继续剩余的管道。
一个典型的例子是一个身份验证或授权处理程序,如果用户未通过身份验证,它不会继续剩余的管道。相反,它可能想要返回一个401 Unauthorized
响应
let checkUserIsLoggedIn : EndpointMiddleware =
fun (next: EndpointHandler) (ctx: HttpContext) ->
if isNotNull ctx.User && ctx.User.Identity.IsAuthenticated then
next ctx
else
setStatusCode 401 ctx
Task.CompletedTask
在else
子句中,checkUserIsLoggedIn
处理程序返回一个401 Unauthorized
HTTP响应,并通过不调用next
而是一个已经完成的任务来跳过剩余的EndpointHandler
管道。
如果您将EndpointMiddleware
定义为一个带有task {}
CE的示例,则可以按照以下方式重写它
let checkUserIsLoggedIn : EndpointMiddleware =
fun (next: EndpointHandler) (ctx: HttpContext) ->
task {
if isNotNull ctx.User && ctx.User.Identity.IsAuthenticated then
return! next ctx
else
return ctx.SetStatusCode 401
}
还可以使用EndpointHandler
来实现这一点,但是必须显式启动响应
let checkUserIsLoggedIn : EndpointHandler =
fun (ctx: HttpContext) ->
if isNotNull ctx.User && ctx.User.Identity.IsAuthenticated then
Task.CompletedTask
else
ctx.SetStatusCode 401
text "Unauthorized" ctx // start response
基础
将 Oxpecker 插入 ASP.NET Core
安装Oxpecker NuGet包
PM> Install-Package Oxpecker
创建一个网络应用,并将其连接到ASP.NET Core中间件
open Oxpecker
// usually your application consists of several routes
let webApp = [
route "/" <| text "Hello world"
route "/ping" <| text "pong"
]
// sometimes it can only be a single route
let webApp1 = route "/" <| "Hello Oxpecker"
// or it can only be a single "MultiEndpoint" route
let webApp2 = GET [
route "/" <| "Hello Oxpecker"
]
let configureApp (appBuilder: IApplicationBuilder) =
appBuilder
.UseRouting()
.UseOxpecker(webApp) // Add Oxpecker to the ASP.NET Core pipeline, should go after UseRouting
//.UseOxpecker(webApp1) will work
//.UseOxpecker(webApp2) will also work
|> ignore
let configureServices (services: IServiceCollection) =
services
.AddRouting()
.AddOxpecker() // Register default Oxpecker dependencies
|> ignore
[<EntryPoint>]
let main _ =
let builder = WebApplication.CreateBuilder(args)
configureServices builder.Services
let app = builder.Build()
configureApp app
app.Run()
0
依赖关系管理
ASP.NET Core 内置了依赖管理功能,与Oxpecker开箱即用
注册服务
与任何其他ASP.NET Core网络应用一样,注册服务的方式相同
let configureServices (services : IServiceCollection) =
// Add default Oxpecker dependencies
services.AddOxpecker() |> ignore
// Add other dependencies
// ...
检索服务
您可以通过Oxpecker的EndpointHandler
函数中的内置服务定位器(RequestServices
)来检索已注册的服务,该服务包含一个HttpContext
对象
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let fooBar =
ctx.RequestServices.GetService(typeof<IFooBar>)
:?> IFooBar
// Do something with `fooBar`...
// Return a Task
Oxpecker还有一个名为GetService<'T>
的附加HttpContext
扩展方法,可以使代码更简洁
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let fooBar = ctx.GetService<IFooBar>()
// Do something with `fooBar`...
// Return a Task
还有一些扩展方法可以用于检索默认依赖项,例如IWebHostEnvironment
或ILogger
对象,有关这些内容,请参阅本文件的相应部分。
功能化依赖注入
但是,如果您更愿意使用更功能化的依赖注入方法,则不应使用基于容器的策略,而应遵循Env策略。
该方法在文章https://medium.com/@lanayx/dependency-injection-in-f-the-missing-manual-d376e9cafd0f 中进行了描述,并且要了解其实践方式,您可以参考存储库中的CRUD示例。
多个环境和配置
ASP.NET Core内建了对多个环境的处理和配置管理的支持,它们都与Oxpecker配合工作。
此外,Oxpecker提供了一个名为GetHostingEnvironment()
的扩展方法,可以用于轻松地从EndpointHandler
函数中检索IWebHostEnvironment
对象
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let env = ctx.GetHostingEnvironment()
// Do something with `env`...
// Return a Task
可以通过GetService<'T>
扩展方法检索配置选项
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let settings = ctx.GetService<IOptions<MySettings>>()
// Do something with `settings`...
// Return a Task
如果您需要在配置服务时访问配置,可以按照如下方式进行访问
let configureServices (services: IServiceCollection) =
let serviceProvider = services.BuildServiceProvider()
let settings = serviceProvider.GetService<IConfiguration>()
// Configure services using the `settings`...
services.AddOxpecker() |> ignore
日志记录
ASP.NET Core内建了一个与Oxpecker开箱即用的日志记录API。
在EndpointHandler函数中记录日志
您可以通过GetLogger<'T>
或GetLogger (categoryName : string)
扩展方法检索一个ILogger
对象(可以用于记录日志)。
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
// Retrieve an ILogger through one of the extension methods
let loggerA = ctx.GetLogger<ModuleName>()
let loggerB = ctx.GetLogger("someHandler")
// Log some data
loggerA.LogCritical("Something critical")
loggerB.LogInformation("Logging some random info")
// etc.
// Return a Task
错误处理
Oxpecker没有内建的错误处理或未找到处理机制,因为它可以很容易地使用以下函数实现,这些函数应该在Oxpecker中间件之前和之后注册
// error handling middleware
let errorHandler (ctx: HttpContext) (next: RequestDelegate) =
task {
try
return! next.Invoke(ctx)
with
| :? ModelBindException
| :? RouteParseException as ex ->
let logger = ctx.GetLogger()
logger.LogWarning(ex, "Unhandled 400 error")
ctx.SetStatusCode StatusCodes.Status400BadRequest
return! ctx.WriteHtmlView(errorView 400 (string ex))
| ex ->
let logger = ctx.GetLogger()
logger.LogError(ex, "Unhandled 500 error")
ctx.SetStatusCode StatusCodes.Status500InternalServerError
return! ctx.WriteHtmlView(errorView 500 (string ex))
} :> Task
// not found terminal middleware
let notFoundHandler (ctx: HttpContext) =
let logger = ctx.GetLogger()
logger.LogWarning("Unhandled 404 error")
ctx.SetStatusCode 404
ctx.WriteHtmlView(errorView 404 "Page not found!")
///...
let configureApp (appBuilder: IApplicationBuilder) =
appBuilder
.UseRouting()
.Use(errorHandler) // Add error handling middleware BEFORE Oxpecker
.UseOxpecker(endpoints)
.Run(notFoundHandler) // Add not found middleware AFTER Oxpecker
Web 请求处理
Oxpecker附带了一组默认的HttpContext
扩展方法和默认的EndpointHandler
函数,可用于构建丰富的网络应用。
HTTP 头
在Oxpecker中处理HTTP头很直接。扩展方法TryGetHeaderValue (key: string)
尝试检索给定HTTP头的值,然后返回Some string
或者None
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let someValue =
match ctx.TryGetHeaderValue "X-MyOwnHeader" with
| None -> "default value"
| Some headerValue -> headerValue
// Do something with `someValue`...
// Return a Task
可以通过扩展方法SetHttpHeader (key: string) (value: obj)
设置响应中的HTTP头
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.SetHttpHeader "X-CustomHeader" "some-value"
// Do other stuff...
// Return a Task
您还可以通过setHttpHeader
HTTP处理程序设置HTTP头
let customHeader : EndpointHandler =
setHttpHeader "X-CustomHeader" "Some value"
let webApp = [
route "/foo" (customHeader >=> text "Foo")
]
请注意,这些是Oxpecker的附加功能,它们补充了ASP.NET Core框架中现有的HTTP响应头功能。ASP.NET Core通过ctx.Request.GetTypedHeaders()
方法提供了更高级的HTTP响应头功能。
HTTP 动词
Oxpecker公开了一组功能,可以基于请求的HTTP动词过滤请求
GET
POST
PUT
PATCH
DELETE
HEAD
OPTIONS
TRACE
CONNECT
还有一个额外的GET_HEAD
处理程序,可以同时过滤HTTP的GET
和HEAD
请求。
根据HTTP动词过滤请求在实现根据动词(例如GET
与POST
)行为不同的路由时非常有用。
let submitFooHandler : EndpointHandler =
// Do something
let submitBarHandler : EndpointHandler =
// Do something
let webApp = [
// Filters for GET requests
GET [
route "/foo" <| text "Foo"
route "/bar" <| text "Bar"
]
// Filters for POST requests
POST [
route "/foo" <| submitFooHandler
route "/bar" <| submitBarHandler
]
]
如果您需要从EndpointHandler
函数内部检查请求的HTTP动词,则可以使用默认的ASP.NET Core HttpMethods
类。
open Microsoft.AspNetCore.Http
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
if HttpMethods.IsPut ctx.Request.Method then
// Do something
else
// Do something else
// Return a Task
GET_HEAD
是一个特殊函数,可以用来同时启用资源的GET
和HEAD
请求。在启用缓存且客户端可能想要在发出GET
请求之前发送HEAD
请求以检查ETag
或Last-Modified
HTTP响应头时,这非常有用。
您还可以使用applyHttpVerbsToEndpoints
函数创建自定义HTTP动词组合。
let GET_HEAD_OPTIONS: Endpoint seq -> Endpoint =
applyHttpVerbsToEndpoints(Verbs [ HttpVerb.GET; HttpVerb.HEAD; HttpVerb.OPTIONS ])
let webApp = [
GET_HEAD_OPTIONS [
route "/foo" <| text "Foo"
route "/bar" <| text "Bar"
]
]
HTTP 状态码
设置响应的HTTP状态码可以通过SetStatusCode (httpStatusCode: int)
扩展方法或使用setStatusCode (statusCode: int)
函数完成。
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.SetStatusCode 200
// Return a Task
// or...
let someHandler : EndpointHandler =
setStatusCode 200
>=> text "Hello World"
路由
Oxpecker提供了一些路由功能来满足大多数使用场景。请注意,Oxpecker路由建立在ASP.NET Core端点路由之上,因此所有路由都不区分大小写。
route
可以通过route
HTTP处理程序以最简单的方式执行路由。
let webApp = [
route "/foo" <| text "Foo"
route "/bar" <| text "Bar"
]
routef
如果路由包含用户定义的参数,则可以使用routef
HTTP处理程序。
let fooHandler first last age : EndpointHandler =
fun (ctx: HttpContext) ->
(sprintf "First: %s, Last: %s, Age: %i" first last age
|> text) ctx
let webApp = [
routef "/foo/{%s}/{%s}/{%i}" fooHandler
routef "/bar/{%O:guid}" (fun (guid: Guid) -> text (string guid))
]
routef
HTTP处理程序接受两个参数 - 一个格式字符串和一个EndpointHandler
函数。
格式字符串支持以下格式字符
格式字符 | 类型 |
---|---|
%b |
bool |
%c |
char |
%s |
string |
%i |
int |
%d |
int64 |
%f |
float /double |
%u |
uint64 |
%O |
Any object (带有约束) |
注意:routef
处理程序只能处理最多5个路由参数。不建议在路由中使用超过3个参数,但如果确实需要很多,可以使用带有利用.TryGetRouteValue
扩展方法的EndpointHandler
函数的route
。
route "/{a}/{b}/{c}/{d}/{e}/{f}" (fun ctx ->
let a = ctx.TryGetRouteValue("a") |> Option.defaultValue ""
let b = ctx.TryGetRouteValue("b") |> Option.defaultValue ""
let c = ctx.TryGetRouteValue("c") |> Option.defaultValue ""
let d = ctx.TryGetRouteValue("d") |> Option.defaultValue ""
let e = ctx.TryGetRouteValue("e") |> Option.defaultValue ""
let f = ctx.TryGetRouteValue("f") |> Option.defaultValue ""
text (sprintf "%s %s %s %s %s %s", a b c d e f) ctx
)
subRoute
它允许您在不重复已预先过滤的路由部分的情况下对路由进行分类。
let webApp =
subRoute "/api" [
subRoute "/v1" [
route "/foo" <| text "Foo 1"
route "/bar" <| text "Bar 1"
]
subRoute "/v2" [
route "/foo" <| text "Foo 2"
route "/bar" <| text "Bar 2"
]
]
在此示例中,检索“Bar 2”的最终URL为http[s]://your-domain.com/api/v2/bar
。
addMetadata
它允许您向路由添加元数据,这些元数据可以在管道的后续部分中使用。
let webApp =
GET [
route "/foo" (text "Foo") |> addMetadata "foo"
]
configureEndpoint
此函数允许您使用ASP.NET .With*
扩展方法配置端点。
let webApp =
GET [
route "/foo" (text "Foo")
|> configureEndpoint
_.WithMetadata("foo")
.WithDisplayName("Foo")
]
查询字符串
在Oxpecker中与查询字符串的工作方式类似于处理HTTP响应头。扩展方法TryGetQueryValue (key : string)
尝试检索给定查询字符串参数的值,然后返回Some string
或None
。
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let someValue =
match ctx.TryGetQueryValue "q" with
| None -> "default value"
| Some q -> q
// Do something with `someValue`...
// Return a Task
您还可以通过ctx.Request.Query
对象访问查询字符串,该对象返回一个IQueryCollection
对象,允许您对其进行更多操作。
最后但同样重要的是,还有一个名为BindQuery<'T>
的HttpContext
扩展方法,它允许您将整个查询字符串绑定到类型为'T
的对象(请参阅绑定查询字符串)。
模型绑定
Oxpecker默认提供了一些HttpContext
扩展方法和等价的EndpointHandler
函数,这使得可以将HTTP请求的有效负载或查询字符串绑定到自定义对象。
绑定 JSON
可以使用BindJson[T]()
扩展方法将JSON有效负载绑定到类型
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let submitCar : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Binds a JSON payload to a Car object
let! car = ctx.BindJson<Car>()
// Sends the object back to the client
return! ctx.Write <| TypedResults.Ok car
}
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [
route "/car" submitCar
]
]
或者,您也可以使用bindJson
http处理程序。
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [
route "/car" (bindJson<Car> (fun car -> %TypedResults.Ok car))
]
]
这两种方式,无论是HttpContext
扩展方法还是EndpointHandler
函数,都会尝试创建类型null
),在进一步处理之前可能需要额外的null
检查。
请注意,为了让模型绑定工作,记录类型必须用[<CLIMutable>]
属性装饰,以确保类型具有无参构造函数。
底层的JSON序列化器可以在应用程序启动时作为依赖项进行配置(参见JSON)。
绑定表单
扩展方法BindForm[T] (?cultureInfo : CultureInfo)
可以将表单数据绑定到类型CultureInfo
对象,用于解析如DateTime
对象或浮点数等特定于文化的数据。
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let submitCar : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Binds a form payload to a Car object
let! car = ctx.BindForm<Car>()
// or with a CultureInfo:
let british = CultureInfo.CreateSpecificCulture("en-GB")
let! car2 = ctx.BindForm<Car>(british)
// Sends the object back to the client
return! ctx.Write <| Ok car
}
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [ route "/car" submitCar ]
]
或使用bindForm
和bindFormC
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let british = CultureInfo.CreateSpecificCulture("en-GB")
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [
route "/car" (bindForm<Car> (fun model -> %Ok model))
route "/britishCar" (bindFormC<Car> british (fun model -> %Ok model))
]
]
与前面的示例类似,记录类型必须用[<CLIMutable>]
属性装饰,以便模型绑定工作。
绑定查询字符串
扩展方法BindQuery[T] (?cultureInfo: CultureInfo)
可以将查询字符串参数绑定到类型CultureInfo
对象,以解析如DateTime
对象和浮点数等特定于文化的数据。
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let submitCar : EndpointHandler =
fun (ctx: HttpContext) ->
// Binds the query string to a Car object
let car = ctx.BindQuery<Car>()
// or with a CultureInfo:
let british = CultureInfo.CreateSpecificCulture("en-GB")
let car2 = ctx.BindQuery<Car>(british)
// Sends the object back to the client
ctx.Write <| Ok car
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
route "/car" <| submitCar
]
]
或使用bindQuery
bindQueryC
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let british = CultureInfo.CreateSpecificCulture("en-GB")
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [
route "/car" (bindQuery<Car> (fun model -> %Ok model))
route "/britishCar" (bindQueryC<Car> british (fun model -> %Ok model))
]
]
与前面的示例类似,记录类型必须用[<CLIMutable>]
属性装饰,以便模型绑定工作。
文件上传
ASP.NET Core使得处理上传的文件变得非常简单。
可以使用HttpContext.Request.Form.Files
集合处理一个或多个客户端发送的小文件。
let fileUploadHandler : EndpointHandler =
fun (ctx: HttpContext) ->
match ctx.Request.HasFormContentType with
| false ->
ctx.Write <| BadRequest()
| true ->
ctx.Request.Form.Files
|> Seq.fold (fun acc file -> $"{acc}\n{file.FileName}") ""
|> ctx.WriteText
let webApp = [ route "/upload" fileUploadHandler ]
您也可以通过利用IFormFeature
和ReadFormAsync
方法来读取上传的文件。
let fileUploadHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
let formFeature = ctx.Features.Get<IFormFeature>()
let! form = formFeature.ReadFormAsync CancellationToken.None
return!
form.Files
|> Seq.fold (fun acc file -> $"{acc}\n{file.FileName}") ""
|> ctx.WriteText
}
let webApp = [ route "/upload" fileUploadHandler ]
对于大文件上传,建议流式传输文件,以避免资源耗尽。
有关在ASP.NET Core中处理大文件上传的更多信息,请参阅StackOverflow上的大文件上传。
WebSockets
Oxpecker不提供任何额外的包装,完全依赖于ASP.NET Core WebSocket支持。
let configureApp (appBuilder: IApplicationBuilder) =
appBuilder
.UseRouting()
.UseOxpecker(webApp) // Add Oxpecker
.UseWebSockets() // Add WebSockets
|> ignore
身份验证和授权
Oxpecker的安全模型与Minimal API安全模型相同,请确保您非常熟悉它。主要区别在于,在Oxpecker中,您可以在单个端点和一组端点上方便地调用configureEndpoint _.RequireAuthorization
。
let webApp = [
// single endpoint
route "/" (text "Hello World")
|> configureEndpoint
_.DisableAntiforgery()
.RequireAuthorization()
// endpoint group
GET [
route "/index" <| text "index"
route "/ping" <| text "pong"
] |> configureEndpoint _.RequireAuthorization(
AuthorizeAttribute(AuthenticationSchemes = "MyScheme")
)
]
条件请求
条件HTTP头(例如,If-Match
、If-Modified-Since
等)是提高性能(Web缓存)、解决丢失更新问题或执行乐观并发控制时,客户端从Web服务器请求资源的一种常见模式。
Oxpecker提供validatePreconditions
端点处理程序,可用于对给定HTTP请求的ETag
和/或Last-Modified
值进行预验证检查。
let someHandler (eTag : string)
(lastModified : DateTimeOffset)
(content : string) =
let eTagHeader = Some (EntityTagHelper.createETag eTag)
validatePreconditions eTagHeader (Some lastModified) >=> text content
validatePreconditions
中间件接受两个可选参数 - 一个 eTag
和一个 lastModified
日期时间值 - 这些参数将被用于验证一个条件 HTTP 请求。如果所有条件都满足,或者没有提交任何条件,则调用 Oxpecker 管道中的下一个 next
http 处理器。否则,如果其中一个预条件失败或自上次检查以来资源未更改,则返回一个 412 预条件失败
或 304 未修改
的响应。
ETag(实体标签) 值是由 web 服务器分配给 URL 中找到的资源特定版本的不可见标识符。 Last-Modified 值提供了一个时间戳,指示原始服务器认为选择的表示内容最后一次修改的日期和时间。
Oxpecker 的 validatePreconditions
端点中间件验证以下条件 HTTP 头部
If-Match
If-None-Match
If-Modified-Since
If-Unmodified-Since
If-Range
HTTP 头部不会被作为 validatePreconditions
http 处理器的一部分进行验证,因为它是一个特定于流的检查,由 Oxpecker 的 Streaming 功能处理。
或者 Oxpecker 提供了一个 HttpContext
扩展方法 ValidatePreconditions(eTag, lastModified)
,可以用来创建自定义的条件端点中间件。 ValidatePreconditions
方法接受相同的两个可选参数,并返回一个类型为 Precondition
的结果。
Precondition
联合类型包含以下情况
情况 | 描述和推荐操作 |
---|---|
NoConditionsSpecified |
没有发生验证,因为客户端没有发送任何条件 HTTP 头部。像往常一样处理 Web 请求。 |
ConditionFailed |
至少有一个条件无法满足。建议向客户端返回 412 状态码(为此可以使用 HttpContext.PreconditionFailedResponse 方法)。 |
ResourceNotModified |
自上次访问以来资源未更改。服务器可以跳过处理此请求并返回一个 304 状态码给客户端(为此可以使用 HttpContext.NotModifiedResponse 方法)。 |
AllConditionsMet |
所有预条件都满足。服务器应继续正常处理请求。 |
validatePreconditions
http 处理器和 ValidatePreconditions
扩展方法不仅验证所有条件 HTTP 头部,还根据 HTTP 规范设置所需的 ETag
和/或 Last-Modified
HTTP 响应头。
这两个函数遵循最新的 HTTP 指南,并按照在 RFC 2616 中定义的正确优先级验证所有条件头。
示例:使用 HttpContext.ValidatePreconditions
// Pass an optional eTag and lastModified timestamp into the handler, because generating an eTag might require to load the entire resource into memory and therefore this is not something which should be done on every request.
let someHttpHandler eTag lastModified : EndpointHandler =
fun (ctx: HttpContext) ->
task {
match ctx.ValidatePreconditions(eTag, lastModified) with
| ConditionFailed -> return ctx.PreconditionFailedResponse()
| ResourceNotModified -> return ctx.NotModifiedResponse()
| AllConditionsMet | NoConditionsSpecified ->
// Continue as normal
// Do stuff
}
let webApp = [
route "/" <| text "Hello World"
route "/foo" <| someHttpHandler None None
]
响应写入
在 Oxpecker 中向客户端发送响应可以通过一系列小的 HttpContext
扩展方法及其等效的 EndpointHandler
函数来完成。
写入字节数
WriteBytes (data: byte[])
扩展方法和 bytes (data: byte[])
端点处理器都将一个 byte 数组
写入 HTTP 请求的响应流。
let someHandler (data: byte[]) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteBytes data
}
// or...
let someHandler (data: byte[]) : EndpointHandler =
// Do stuff
bytes data
这两个函数还将设置 Content-Length
HTTP 头为 byte 数组
的长度。
bytes
http 处理器(及其 HttpContext
扩展方法等效)在您想为尚未由 Oxpecker 提供的特定媒体类型创建自己的响应写入函数时非常有用。
例如,Oxpecker 没有反序列化和将 YAML 响应写回客户端的功能。然而,您可以引用另一个第三方库,该库可以将对象序列化为 YAML 字符串,然后像这样创建自己的 yaml
http 处理器
let yaml (x: obj) : EndpointHandler =
setHttpHeader "Content-Type" "text/yaml"
>=> bytes (x |> YamlSerializer.toYaml |> Encoding.UTF8.GetBytes)
写入文本
WriteText (str : string)
扩展方法和 text (str: string)
端点处理程序会将字符串以 UTF8 格式写入响应中,并在响应中设置 Content-Type
HTTP 标头为 text/plain
let someHandler (str: string) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteText str
}
// or...
let someHandler (str: string) : EndpointHandler =
// Do stuff
text str
写入 JSON
WriteJson<'T> (dataObj : 'T)
扩展方法和 json<'T> (dataObj: 'T)
端点处理程序都会将对象序列化为 JSON 字符串,并将输出写入 HTTP 请求的响应流中。它们还将响应中的 Content-Length
HTTP 标头和 Content-Type
标头设置为 application/json
let someHandler (animal: Animal) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteJson animal
}
// or...
let someHandler (animal: Animal) : EndpointHandler =
// Do stuff
json animal
WriteJsonChunked<'T> (dataObj: 'T)
扩展方法和 jsonChunked (dataObj: 'T)
端点处理程序将直接写入 HTTP 请求的响应流,而无需额外缓存到字节数组中。它们不会设置 Content-Length
标头,而是设置 Transfer-Encoding: chunked
标头和 Content-Type: application/json
let someHandler (person: Person) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteJsonChunked person
}
// or...
let someHandler (person: Person) : EndpointHandler =
// Do stuff
jsonChunked person
底层的 JSON 序列化器在应用程序启动时配置为依赖项,默认为 System.Text.Json
(当您写入 services.AddOxpecker()
时)。您可以实现 Serializers.IJsonSerializer
接口来插入自定义 JSON 序列化器。
let configureServices (services : IServiceCollection) =
// First register all default Oxpecker dependencies
services.AddOxpecker() |> ignore
// Now register custom serializer
services.AddSingleton<Serializers.IJsonSerializer>(CustomSerializer()) |> ignore
// or use default STJ serializer, but with different options
services.AddSingleton<Serializers.IJsonSerializer>(
SystemTextJson.Serializer(specificOptions)) |> ignore
写入 IResult
如果您喜欢 ASP.NET Core IResult 提供的特性,您可能会很高兴地知道,Oxpecker 也支持它。您可以使用 Microsoft.AspNetCore.Http.TypedResults
简化带有状态码的响应返回。
open Oxpecker
open type Microsoft.AspNetCore.Http.TypedResults
let johnDoe = {|
FirstName = "John"
LastName = "Doe"
|}
let app = [
route "/" <| text "Hello World"
route "/john" <| %Ok johnDoe // returns 200 OK with JSON body
route "/bad" <| %BadRequest() // returns 400 BadRequest with empty body
]
使用 %
运算符可以将 IResult
转换为 EndpointHandler
。您还可以使用 .Write
扩展方法在 EndpointHandler 中执行转换。
let myHandler : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.Write <| TypedResults.Ok johnDoe
写入 HTML 字符串
WriteHtmlString (html: string)
扩展方法和 htmlString (html: string)
端点处理程序等同于写入文本,但它们将 Content-Type
标头设置为 text/html
。
let someHandler (dataObj: obj) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteHtmlString "<html><head></head><body>Hello World</body></html>"
}
// or...
let someHandler (dataObj: obj) : EndpointHandler =
// Do stuff
htmlString "<html><head></head><body>Hello World</body></html>"
写入 HTML 视图
Oxpecker 为功能开发者提供自己的非常强大的视图引擎(请参阅Oxpecker 视图引擎)。WriteHtmlView (htmlView : HtmlElement)
扩展方法和 htmlView (htmlView : HtmlElement)
HTTP 处理程序都将给定的 HTML 视图编译成有效的 HTML 代码,并将其写入 HTTP 请求的响应流中。此外,它们还将 Content-Length
HTTP 标头设置为正确值,将 Content-Type
标头设置为 text/html
。
let indexView =
html() {
head() {
title() { "Oxpecker" }
}
body() {
h1(id="Header") { "Oxpecker" }
p() { "Hello World." }
}
}
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteHtmlView indexView
}
// or...
let someHandler : EndpointHandler =
// Do stuff
htmlView indexView
您还可以使用 htmlChunked
和 htmlViewChunked
HTTP 处理程序以及相应的 WriteHtmlChunked
和 WriteHtmlViewChunked
扩展方法来进行 HTML 流。
let indexView =
html() {
head() {
title() { "Oxpecker" }
}
body() {
h1(id="Header") { "Oxpecker" }
p() { "Hello World." }
}
}
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteHtmlViewChunked indexView
}
// or...
let someHandler : EndpointHandler =
// Do stuff
htmlViewChunked indexView
警告:虽然运行时速度快,但使用许多长的 CE 表达式可能会减慢您的项目编译和 IDE 体验(请参阅问题),因此您可能决定使用不同的视图引擎。有多种视图引擎可供选择:Giraffe.ViewEngine、Feliz.ViewEngine、Falco.Markup 或您可以自己编写!要插入外部视图引擎,您可以编写一个简单的扩展。
[<Extension>]
static member WriteMyHtmlView(ctx: HttpContext, htmlView: MyHtmlElement) =
let bytes = htmlView |> convertToBytes
ctx.Response.ContentType <- "text/html; charset=utf-8"
ctx.WriteBytes bytes
// ...
let myHtmlView (htmlView: MyHtmlElement) : EndpointHandler =
fun (ctx: HttpContext) -> ctx.WriteMyHtmlView htmlView
流式传输
有时必须将大文件或数据块发送到客户端,为了避免将整个数据加载到内存中,Oxpecker 网络应用程序可以使用流以更高效的方式发送响应。
可以用于将类型为 Stream
的对象以流的形式流式传输到客户端的 WriteStream
扩展方法和 streamData
端点处理程序。
这两个函数接受以下参数
enableRangeProcessing
:如果为 true,则客户端可以请求流式传输数据的一个子范围(当客户端想要在暂停下载后继续流式传输、互联网连接丢失等情况时很有用)。stream
:要返回给客户端的流对象。eTag
:用于条件请求的实体标头标签(请参阅条件请求)。lastModified
:用于条件请求的最后修改时间戳(参见 条件请求)。
如果设置了eTag
或lastModified
时间戳,那么在响应过程中这两个函数也会设置ETag
和/或Last-Modified
HTTP头。
let someStream : Stream = ...
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteStream(
true, // enableRangeProcessing
someStream,
None, // eTag
None) // lastModified
}
// or...
let someHandler : EndpointHandler =
// Do stuff
streamData
true // enableRangeProcessing
someStream
None // eTag
None // lastModified
在大多数情况下,Web应用程序将希望直接从本地文件系统中流式传输文件。在这种情况下,你可以使用WriteFileStream
扩展方法或streamFile
HTTP处理程序,它们与WriteStream
和streamData
相同,只不过它们接受一个相对路径或绝对路径的filePath
而不是Stream
对象。
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteFileStream(
true, // enableRangeProcessing
"large-file.zip",
None, // eTag
None) // lastModified
}
// or...
let someHandler : EndpointHandler =
// Do stuff
streamFile
true // enableRangeProcessing
"large-file.zip"
None // eTag
None // lastModified
Oxpecker中的所有流式传输函数也将验证条件HTTP头,包括如果设置了enableRangeProcessing
为true
的If-Range
HTTP头。
重定向
可以用于在处理传入的Web请求时将客户端重定向到不同位置的redirectTo (location: string) (permanent: bool)
端点处理程序。
let webApp = [
route "/new" <| text "Hello World"
route "/old" <| redirectTo "https://myserver.com/new" true
]
请注意,如果将permanent
标志设置为true
,则Oxpecker Web应用程序将向浏览器发送一个301
HTTP状态码,告诉它们重定向是永久的。这通常会导致浏览器缓存信息并且不再第二次点击已弃用的URL。如果不希望这样,请将permanent
设置为false
(302
HTTP状态码),以确保浏览器在重定向到(临时的)新URL之前继续点击旧URL。
响应缓存
ASP.NET Core附带标准
如果您尚未使用两个ASP.NET Core元包之一(Microsoft.AspNetCore.App
或Microsoft.AspNetCore.All
),则必须添加对Microsoft.AspNetCore.ResponseCaching NuGet包的额外引用。
添加NuGet包后,您需要在注册Oxpecker之前将其内的响应缓存中间件添加到应用程序的启动代码中。
let configureServices (services : IServiceCollection) =
services
.AddResponseCaching() // <-- Here the order doesn't matter
.AddOxpecker() // This is just registering dependencies
|> ignore
let configureApp (app : IApplicationBuilder) =
app
.UseStaticFiles() // Optional if you use static files
.UseAuthentication() // Optional if you use authentication
.UseResponseCaching() // <-- Before UseOxpecker webApp
.UseOxpecker webApp
在设置ASP.NET Core响应缓存中间件后,您可以使用Oxpecker的响应缓存HTTP处理程序将响应缓存添加到您的路由中。
// A test handler which generates a new GUID on every request
let generateGuidHandler : EndpointHandler =
fun ctx -> ctx.WriteText(Guid.NewGuid().ToString())
let cacheHeader = Some <| CacheControlHeaderValue(MaxAge = TimeSpan.FromSeconds(30), Public = true)
let webApp = [
route "/route1" (responseCaching cacheHeader None None >=> generateGuidHandler)
route "/route2" (noResponseCaching >=> generateGuidHandler)
]
对/route1
的请求可以缓存长达30秒,而对/route2
的请求将完全禁用响应缓存。
注意:如果您使用Postman测试上述代码,请确保您已在Postman中禁用无缓存功能,以便测试正确的缓存行为。
Oxpecker总共提供2个端点处理程序,可用于配置端点的响应缓存。
在上面的示例中,我们使用了noResponseCaching
端点处理程序来完全禁用在客户端和任何代理服务器上的响应缓存。该noResponseCaching
端点处理程序将在响应中发送以下HTTP头:
Cache-Control: no-store, no-cache
Pragma: no-cache
Expires: -1
responseCaching
端点处理程序将在客户端和/或在代理服务器上启用响应缓存。该CacheControlHeaderValue
对象将控制Cache-Control
指令。
Public = true
表示不仅允许客户端缓存给定缓存持续期的响应,还允许任何中介代理服务器以及ASP.NET Core中间件。这对于不包含任何特定用户数据、身份验证数据或任何cookies的HTTP GET/HEAD端点及其响应数据不频繁更改的情况很有用。
Public = false
表示只有最终客户端可以存储给定缓存持续期的响应。代理服务器和ASP.NET Core响应缓存中间件不得缓存响应。
responseCaching
端点处理器有两个额外的参数:vary
和 varyByQueryKeys
。
变体
vary
参数指定哪些 HTTP 请求头必须受到尊重以更改缓存的响应。例如,如果端点根据客户端的 Accept
标头(内容协商)返回不同的响应(Content-Type
),则在从缓存返回响应时必须考虑 Accept
标头。如果 web 服务器启用了响应压缩,则也适用同样的情况。如果响应根据客户端接受的压缩算法而变化,则缓存在提供来自缓存的响应时也必须尊重客户端的 Accept-Encoding
HTTP 标头。
let cacheHeader = Some <| CacheControlHeaderValue(MaxAge = TimeSpan.FromSeconds(30), Public = true)
// Cache for 30 seconds without any vary headers
publicResponseCaching cacheHeader None None
// Cache for 30 seconds with Accept and Accept-Encoding as vary headers
publicResponseCaching cacheHeader (Some "Accept, Accept-Encoding") None
varyByQueryKeys
ASP.NET Core 响应缓存中间件提供了一项不是响应 HTTP 头的一部分的额外功能。默认情况下,如果路由可缓存,则中间件将尝试返回缓存的响应,即使查询参数不同也是如此。
例如,如果对 /foo/bar
的请求已经被缓存,那么如果请求 /foo/bar?query1=a
或 /foo/bar?query1=a&query2=b
,也会返回缓存的版本。
有时这可能不是所希望的,而 VaryByQueryKeys
功能允许中间件根据请求的查询键改变其缓存的响应。
通用的 responseCaching
端点处理器是最基本的响应缓存处理器,可用于配置自定义响应缓存处理器以及使用 VaryByQueryKeys
功能。
responseCaching
(Some (CacheControlHeaderValue(MaxAge = TimeSpan.FromSeconds(30)))
(Some "Accept, Accept-Encoding")
(Some [| "query1"; "query2" |])
第一个参数是类型 CacheControlHeaderValue。
第二个参数是一个 string option
,它定义了 vary
参数。
第三个也是最后一个参数是一个 string[] option
,它定义了一个可选的查询参数值列表,必须使用它来让 ASP.NET Core 响应缓存中间件通过这些参数值更改缓存的响应。请注意,此功能仅适用于 ASP.NET Core 响应缓存中间件,任何中间代理服务器都不会尊重它。
响应压缩
ASP.NET Core 拥有它自己的 响应压缩中间件,它可以直接与 Oxpecker 一起使用。无需附加的功能或 http 处理器,就可以使其与 Oxpecker web 应用程序一起使用。
测试
对 Oxpecker 应用程序的集成测试遵循 ASP.NET Core 测试 的概念。您可以在本存储库本身中查看测试示例:Oxpecker.Tests
产品 | 版本 兼容和更多的计算目标框架版本。 |
---|---|
.NET | net8.0 兼容。 net8.0-android 已计算。 net8.0-browser 已计算。 net8.0-ios 已计算。 net8.0-maccatalyst 已计算。 net8.0-macos 已计算。 net8.0-tvos 已计算。 net8.0-windows 已计算。 |
-
net8.0
- FSharp.Core (>= 8.0.301)
- Microsoft.IO.RecyclableMemoryStream (>= 3.0.0)
- Oxpecker.ViewEngine (>= 0.12.0)
NuGet 包 (1)
显示依赖 Oxpecker 的前 1 个 NuGet 包
包 | 下载 |
---|---|
Oxpecker.OpenApi
Oxpecker 的 OpenApi 支持 |
GitHub 仓库
此包没有被任何流行的 GitHub 仓库使用。
版本 | 下载 | 最后更新 |
---|---|---|
0.13.1 | 78 | 8/13/2024 |
0.13.0 | 240 | 7/17/2024 |
0.12.0 | 91 | 7/16/2024 |
0.11.1 | 148 | 7/8/2024 |
0.11.0 | 87 | 7/5/2024 |
0.10.1 | 677 | 5/8/2024 |
0.10.0 | 223 | 4/29/2024 |
0.9.3 | 169 | 4/10/2024 |
0.9.2 | 125 | 4/5/2024 |
0.9.1 | 98 | 4/4/2024 |
0.9.0 | 118 | 3/23/2024 |
0.8.1 | 140 | 2/29/2024 |
0.7.1 | 199 | 2/12/2024 |
0.7.0 | 112 | 2/7/2024 |
0.6.1 | 84 | 2/5/2024 |
0.6.0 | 86 | 2/3/2024 |
0.5.1 | 93 | 1/26/2024 |
0.5.0 | 91 | 1/23/2024 |
0.4.0 | 98 | 1/22/2024 |
0.3.0 | 87 | 1/19/2024 |
0.2.0 | 92 | 1/15/2024 |
0.1.0 | 108 | 1/12/2024 |
更新 Oxpecker.ViewEngine 依赖