mirror of
https://github.com/infinite-iroha/touka.git
synced 2026-02-03 00:41:10 +08:00
Compare commits
5 commits
016df0efe4
...
f1ac0dd6ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1ac0dd6ff | ||
|
|
38ff5126e3 | ||
|
|
b4e073ae2f | ||
|
|
af0a99acda | ||
|
|
3ffde5742c |
5 changed files with 248 additions and 26 deletions
61
context.go
61
context.go
|
|
@ -23,6 +23,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/WJQSERVER/wanf"
|
||||
"github.com/fenthope/reco"
|
||||
"github.com/go-json-experiment/json"
|
||||
|
||||
|
|
@ -42,7 +43,7 @@ type Context struct {
|
|||
index int8 // 当前执行到处理链的哪个位置
|
||||
|
||||
mu sync.RWMutex
|
||||
Keys map[string]interface{} // 用于在中间件之间传递数据
|
||||
Keys map[string]any // 用于在中间件之间传递数据
|
||||
|
||||
Errors []error // 用于收集处理过程中的错误
|
||||
|
||||
|
|
@ -77,20 +78,18 @@ func (c *Context) reset(w http.ResponseWriter, req *http.Request) {
|
|||
} else {
|
||||
c.Writer = newResponseWriter(w)
|
||||
}
|
||||
//c.Writer = newResponseWriter(w)
|
||||
|
||||
c.Request = req
|
||||
c.Params = c.Params[:0] // 清空 Params 切片,而不是重新分配,以复用底层数组
|
||||
c.handlers = nil
|
||||
c.index = -1 // 初始为 -1,`Next()` 将其设置为 0
|
||||
c.Keys = make(map[string]interface{}) // 每次请求重新创建 map,避免数据污染
|
||||
c.Keys = make(map[string]any) // 每次请求重新创建 map,避免数据污染
|
||||
c.Errors = c.Errors[:0] // 清空 Errors 切片
|
||||
c.queryCache = nil // 清空查询参数缓存
|
||||
c.formCache = nil // 清空表单数据缓存
|
||||
c.ctx = req.Context() // 使用请求的上下文,继承其取消信号和值
|
||||
c.sameSite = http.SameSiteDefaultMode // 默认 SameSite 模式
|
||||
c.MaxRequestBodySize = c.engine.GlobalMaxRequestBodySize
|
||||
// c.HTTPClient 和 c.engine 保持不变,它们引用 Engine 实例的成员
|
||||
}
|
||||
|
||||
// Next 在处理链中执行下一个处理函数
|
||||
|
|
@ -122,10 +121,10 @@ func (c *Context) AbortWithStatus(code int) {
|
|||
|
||||
// Set 将一个键值对存储到 Context 中
|
||||
// 这是一个线程安全的操作,用于在中间件之间传递数据
|
||||
func (c *Context) Set(key string, value interface{}) {
|
||||
func (c *Context) Set(key string, value any) {
|
||||
c.mu.Lock() // 加写锁
|
||||
if c.Keys == nil {
|
||||
c.Keys = make(map[string]interface{})
|
||||
c.Keys = make(map[string]any)
|
||||
}
|
||||
c.Keys[key] = value
|
||||
c.mu.Unlock() // 解写锁
|
||||
|
|
@ -133,7 +132,7 @@ func (c *Context) Set(key string, value interface{}) {
|
|||
|
||||
// Get 从 Context 中获取一个值
|
||||
// 这是一个线程安全的操作
|
||||
func (c *Context) Get(key string) (value interface{}, exists bool) {
|
||||
func (c *Context) Get(key string) (value any, exists bool) {
|
||||
c.mu.RLock() // 加读锁
|
||||
value, exists = c.Keys[key]
|
||||
c.mu.RUnlock() // 解读锁
|
||||
|
|
@ -208,7 +207,7 @@ func (c *Context) GetDuration(key string) (value time.Duration, exists bool) {
|
|||
|
||||
// MustGet 从 Context 中获取一个值,如果不存在则 panic
|
||||
// 适用于确定值一定存在的场景
|
||||
func (c *Context) MustGet(key string) interface{} {
|
||||
func (c *Context) MustGet(key string) any {
|
||||
if value, exists := c.Get(key); exists {
|
||||
return value
|
||||
}
|
||||
|
|
@ -269,7 +268,7 @@ func (c *Context) Raw(code int, contentType string, data []byte) {
|
|||
}
|
||||
|
||||
// String 向响应写入格式化的字符串
|
||||
func (c *Context) String(code int, format string, values ...interface{}) {
|
||||
func (c *Context) String(code int, format string, values ...any) {
|
||||
c.Writer.WriteHeader(code)
|
||||
c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
|
||||
}
|
||||
|
|
@ -283,7 +282,7 @@ func (c *Context) Text(code int, text string) {
|
|||
|
||||
// JSON 向响应写入 JSON 数据
|
||||
// 设置 Content-Type 为 application/json
|
||||
func (c *Context) JSON(code int, obj interface{}) {
|
||||
func (c *Context) JSON(code int, obj any) {
|
||||
c.Writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.Writer.WriteHeader(code)
|
||||
if err := json.MarshalWrite(c.Writer, obj); err != nil {
|
||||
|
|
@ -295,7 +294,7 @@ func (c *Context) JSON(code int, obj interface{}) {
|
|||
|
||||
// GOB 向响应写入GOB数据
|
||||
// 设置 Content-Type 为 application/octet-stream
|
||||
func (c *Context) GOB(code int, obj interface{}) {
|
||||
func (c *Context) GOB(code int, obj any) {
|
||||
c.Writer.Header().Set("Content-Type", "application/octet-stream") // 设置合适的 Content-Type
|
||||
c.Writer.WriteHeader(code)
|
||||
// GOB 编码
|
||||
|
|
@ -307,11 +306,25 @@ func (c *Context) GOB(code int, obj interface{}) {
|
|||
}
|
||||
}
|
||||
|
||||
// WANF向响应写入WANF数据
|
||||
// 设置 application/vnd.wjqserver.wanf; charset=utf-8
|
||||
func (c *Context) WANF(code int, obj any) {
|
||||
c.Writer.Header().Set("Content-Type", "application/vnd.wjqserver.wanf; charset=utf-8")
|
||||
c.Writer.WriteHeader(code)
|
||||
// WANF 编码
|
||||
encoder := wanf.NewStreamEncoder(c.Writer)
|
||||
if err := encoder.Encode(obj); err != nil {
|
||||
c.AddError(fmt.Errorf("failed to encode WANF: %w", err))
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, fmt.Errorf("failed to encode WANF: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HTML 渲染 HTML 模板
|
||||
// 如果 Engine 配置了 HTMLRender,则使用它进行渲染
|
||||
// 否则,会进行简单的字符串输出
|
||||
// 预留接口,可以扩展为支持多种模板引擎
|
||||
func (c *Context) HTML(code int, name string, obj interface{}) {
|
||||
func (c *Context) HTML(code int, name string, obj any) {
|
||||
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
c.Writer.WriteHeader(code)
|
||||
|
||||
|
|
@ -342,7 +355,7 @@ func (c *Context) Redirect(code int, location string) {
|
|||
}
|
||||
|
||||
// ShouldBindJSON 尝试将请求体绑定到 JSON 对象
|
||||
func (c *Context) ShouldBindJSON(obj interface{}) error {
|
||||
func (c *Context) ShouldBindJSON(obj any) error {
|
||||
if c.Request.Body == nil {
|
||||
return errors.New("request body is empty")
|
||||
}
|
||||
|
|
@ -353,10 +366,28 @@ func (c *Context) ShouldBindJSON(obj interface{}) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// ShouldBindWANF
|
||||
func (c *Context) ShouldBindWANF(obj any) error {
|
||||
if c.Request.Body == nil {
|
||||
return errors.New("request body is empty")
|
||||
}
|
||||
decoder, err := wanf.NewStreamDecoder(c.Request.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WANF decoder: %w", err)
|
||||
}
|
||||
|
||||
if err := decoder.Decode(obj); err != nil {
|
||||
return fmt.Errorf("WANF binding error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deprecated: This function is a reserved placeholder for future API extensions
|
||||
// and is not yet implemented. It will either be properly defined or removed in v2.0.0. Do not use.
|
||||
// ShouldBind 尝试将请求体绑定到各种类型(JSON, Form, XML 等)
|
||||
// 这是一个复杂的通用绑定接口,通常根据 Content-Type 或其他头部来判断绑定方式
|
||||
// 预留接口,可根据项目需求进行扩展
|
||||
func (c *Context) ShouldBind(obj interface{}) error {
|
||||
func (c *Context) ShouldBind(obj any) error {
|
||||
// TODO: 完整的通用绑定逻辑
|
||||
// 可以根据 c.Request.Header.Get("Content-Type") 来判断是 JSON, Form, XML 等
|
||||
// 例如:
|
||||
|
|
@ -409,7 +440,7 @@ func (c *Context) Err() error {
|
|||
// Value returns the value associated with this context for key, or nil if no
|
||||
// value is associated with key.
|
||||
// 可以用于从 Context 中获取与特定键关联的值,包括 Go 原生 Context 的值和 Touka Context 的 Keys
|
||||
func (c *Context) Value(key interface{}) interface{} {
|
||||
func (c *Context) Value(key any) any {
|
||||
if keyAsString, ok := key.(string); ok {
|
||||
if val, exists := c.Get(keyAsString); exists {
|
||||
return val
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ package touka
|
|||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
|
@ -19,13 +18,19 @@ var allowedFileServerMethods = map[string]struct{}{
|
|||
http.MethodHead: {},
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInputFSisNil = errors.New("input FS is nil")
|
||||
ErrMethodNotAllowed = errors.New("method not allowed")
|
||||
)
|
||||
|
||||
// FileServer方式, 返回一个HandleFunc, 统一化处理
|
||||
func FileServer(fs http.FileSystem) HandlerFunc {
|
||||
if fs == nil {
|
||||
return func(c *Context) {
|
||||
c.ErrorUseHandle(500, errors.New("Input FileSystem is nil"))
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, ErrInputFSisNil)
|
||||
}
|
||||
}
|
||||
|
||||
fileServerInstance := http.FileServer(fs)
|
||||
return func(c *Context) {
|
||||
FileServerHandleServe(c, fileServerInstance)
|
||||
|
|
@ -37,7 +42,6 @@ func FileServer(fs http.FileSystem) HandlerFunc {
|
|||
|
||||
func FileServerHandleServe(c *Context, fsHandle http.Handler) {
|
||||
if fsHandle == nil {
|
||||
ErrInputFSisNil := errors.New("Input FileSystem Handle is nil")
|
||||
c.AddError(ErrInputFSisNil)
|
||||
// 500
|
||||
c.ErrorUseHandle(http.StatusInternalServerError, ErrInputFSisNil)
|
||||
|
|
@ -59,7 +63,7 @@ func FileServerHandleServe(c *Context, fsHandle http.Handler) {
|
|||
return
|
||||
} else {
|
||||
// 否则,返回 405 Method Not Allowed
|
||||
c.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, fmt.Errorf("Method %s is Not Allowed on FileServer", c.Request.Method))
|
||||
c.engine.errorHandle.handler(c, http.StatusMethodNotAllowed, ErrMethodNotAllowed)
|
||||
}
|
||||
} else {
|
||||
c.Next()
|
||||
|
|
|
|||
7
go.mod
7
go.mod
|
|
@ -1,15 +1,16 @@
|
|||
module github.com/infinite-iroha/touka
|
||||
|
||||
go 1.24.5
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.8.2
|
||||
github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee
|
||||
github.com/fenthope/reco v0.0.4
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
|
||||
github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
)
|
||||
|
|
|
|||
10
go.sum
10
go.sum
|
|
@ -2,11 +2,13 @@ github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2 h1:AiIHXP21LpK7pFfqUlUstgQEWzjbe
|
|||
github.com/WJQSERVER-STUDIO/go-utils/iox v0.0.2/go.mod h1:mCLqYU32bTmEE6dpj37MKKiZgz70Jh/xyK9vVbq6pok=
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.8.2 h1:PFPLodV0QAfGEP6915J57vIqoKu9cGuuiXG/7C9TNUk=
|
||||
github.com/WJQSERVER-STUDIO/httpc v0.8.2/go.mod h1:8WhHVRO+olDFBSvL5PC/bdMkb6U3vRdPJ4p4pnguV5Y=
|
||||
github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee h1:tJ31DNBn6UhWkk8fiikAQWqULODM+yBcGAEar1tzdZc=
|
||||
github.com/WJQSERVER/wanf v0.0.0-20250810023226-e51d9d0737ee/go.mod h1:q2Pyg+G+s1acMWxrbI4CwS/Yk76/BzLREEdZ8iFwUNE=
|
||||
github.com/fenthope/reco v0.0.4 h1:yo2g3aWwdoMpaZWZX4SdZOW7mCK82RQIU/YI8ZUQThM=
|
||||
github.com/fenthope/reco v0.0.4/go.mod h1:eMyS8HpdMVdJ/2WJt6Cvt8P1EH9Igzj5lSJrgc+0jeg=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
|
||||
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b h1:6Q4zRHXS/YLOl9Ng1b1OOOBWMidAQZR3Gel0UKPC/KU=
|
||||
github.com/go-json-experiment/json v0.0.0-20250813233538-9b1f9ea2e11b/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
|
|
|
|||
184
sse.go
Normal file
184
sse.go
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
// Copyright 2025 WJQSERVER. All rights reserved.
|
||||
// All rights reserved by WJQSERVER, related rights can be exercised by the infinite-iroha organization.
|
||||
package touka
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Event 代表一个服务器发送事件(SSE).
|
||||
type Event struct {
|
||||
// Event 是事件的名称.
|
||||
Event string
|
||||
// Data 是事件的内容, 可以是多行文本.
|
||||
Data string
|
||||
// Id 是事件的唯一标识符.
|
||||
Id string
|
||||
// Retry 是指定客户端在连接丢失后应等待多少毫秒后尝试重新连接.
|
||||
Retry string
|
||||
}
|
||||
|
||||
// Render 将事件格式化并写入给定的 writer.
|
||||
// 通过逐行处理数据, 此方法可防止因数据中包含换行符而导致的CRLF注入问题.
|
||||
// 为了性能, 它使用 bytes.Buffer 并通过 WriteTo 直接写入, 以避免不必要的内存分配.
|
||||
func (e *Event) Render(w io.Writer) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
if len(e.Id) > 0 {
|
||||
buf.WriteString("id: ")
|
||||
buf.WriteString(e.Id)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
if len(e.Event) > 0 {
|
||||
buf.WriteString("event: ")
|
||||
buf.WriteString(e.Event)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
if len(e.Data) > 0 {
|
||||
lines := strings.Split(e.Data, "\n")
|
||||
for _, line := range lines {
|
||||
buf.WriteString("data: ")
|
||||
buf.WriteString(line)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if len(e.Retry) > 0 {
|
||||
buf.WriteString("retry: ")
|
||||
buf.WriteString(e.Retry)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
// 每个事件都以一个额外的换行符结尾.
|
||||
buf.WriteString("\n")
|
||||
|
||||
// 直接将 buffer 的内容写入 writer, 避免生成中间字符串.
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
// EventStream 启动一个 SSE 事件流.
|
||||
// 这是推荐的、更简单安全的方式, 采用阻塞和回调的设计, 框架负责管理连接生命周期.
|
||||
//
|
||||
// 详细用法:
|
||||
//
|
||||
// r.GET("/sse/callback", func(c *touka.Context) {
|
||||
// // streamer 回调函数会在一个循环中被调用.
|
||||
// c.EventStream(func(w io.Writer) bool {
|
||||
// event := touka.Event{
|
||||
// Event: "time-tick",
|
||||
// Data: time.Now().Format(time.RFC1123),
|
||||
// }
|
||||
//
|
||||
// if err := event.Render(w); err != nil {
|
||||
// // 发生写入错误, 停止发送.
|
||||
// return false // 返回 false 结束事件流.
|
||||
// }
|
||||
//
|
||||
// time.Sleep(2 * time.Second)
|
||||
// return true // 返回 true 继续事件流.
|
||||
// })
|
||||
// // 当事件流结束后(例如客户端关闭页面), 这行代码会被执行.
|
||||
// fmt.Println("Client disconnected from /sse/callback")
|
||||
// })
|
||||
func (c *Context) EventStream(streamer func(w io.Writer) bool) {
|
||||
// 为现代网络协议优化头部.
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
c.Writer.Header().Del("Connection")
|
||||
c.Writer.Header().Del("Transfer-Encoding")
|
||||
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.Flush() // 直接调用, ResponseWriter 接口保证了 Flush 方法的存在.
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.Request.Context().Done():
|
||||
return
|
||||
default:
|
||||
if !streamer(c.Writer) {
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EventStreamChan 返回用于 SSE 事件流的 channel.
|
||||
// 这是为高级并发场景设计的、更灵活的API.
|
||||
//
|
||||
// 重要:
|
||||
// - 调用者必须 close(eventChan) 来结束事件流.
|
||||
// - 调用者必须在独立的 goroutine 中消费 errChan 来处理错误和连接断开.
|
||||
// - 为防止 goroutine 泄漏, 建议发送方在 select 中同时监听 c.Request.Context().Done().
|
||||
//
|
||||
// 详细用法:
|
||||
//
|
||||
// r.GET("/sse/channel", func(c *touka.Context) {
|
||||
// eventChan, errChan := c.EventStreamChan()
|
||||
//
|
||||
// // 必须在独立的goroutine中处理错误和连接断开.
|
||||
// go func() {
|
||||
// if err := <-errChan; err != nil {
|
||||
// c.Errorf("SSE channel error: %v", err)
|
||||
// }
|
||||
// }()
|
||||
//
|
||||
// // 在另一个goroutine中异步发送事件.
|
||||
// go func() {
|
||||
// // 重要: 必须在逻辑结束时关闭channel, 以通知框架.
|
||||
// defer close(eventChan)
|
||||
//
|
||||
// for i := 1; i <= 5; i++ {
|
||||
// select {
|
||||
// case <-c.Request.Context().Done():
|
||||
// return // 客户端已断开, 退出 goroutine.
|
||||
// default:
|
||||
// eventChan <- touka.Event{
|
||||
// Id: fmt.Sprintf("%d", i),
|
||||
// Data: "hello from channel",
|
||||
// }
|
||||
// time.Sleep(2 * time.Second)
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
// })
|
||||
func (c *Context) EventStreamChan() (chan<- Event, <-chan error) {
|
||||
eventChan := make(chan Event)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
c.Writer.Header().Del("Connection")
|
||||
c.Writer.Header().Del("Transfer-Encoding")
|
||||
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.Flush()
|
||||
|
||||
go func() {
|
||||
defer close(errChan)
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-eventChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := event.Render(c.Writer); err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
c.Writer.Flush()
|
||||
case <-c.Request.Context().Done():
|
||||
errChan <- c.Request.Context().Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return eventChan, errChan
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue