go web 框架总结


思想

web框架服务主要围绕着请求与响应来展开的

搭建一个web框架的核心思想就是围绕着添加路由,处理用户请求方法进行封装,从而可以使执行代码更加简洁,功能新增更加丰富.

base部分

首先了解原生的web服务的搭建:\

  1. 添加路由:
    使用http.HandleFunc来添加路由:
    第一给参数为添加的路由,第二个参数是hander函数,就是当路由匹配时执行方法

hander函数必须是这两个形参w http.ResponseWriter, req *http.Request,一个是输出的端口,一个是用户传入的数据

  1. 响应请求
    使用http.ListenAndServe方法,一个监听的端口,一个http.Handler 接口,只包含一个方法 ServeHTTP,就是传入的参数可以调serveHTTP方法处理用户的请求.
func indexhander(w http.ResponseWriter, req *http.Request) {
	fmt.Fprintf(w, "URL=%q\n", req.URL.Path) //输出路径
}
func hellohander(w http.ResponseWriter, req *http.Request) {
	for k, v := range req.Header { 
		fmt.Fprintf(w, "header[%q]=%q\n", k, v) //输出头部信息
	}
}
func main() {
	http.HandleFunc("/", indexhander)//第一给参数为添加的路由,第二个参数是hander函数,就是当路由匹配时执行的
	http.HandleFunc("/hello", hellohander)
	log.Fatal(http.ListenAndServe(":8888", nil)) //开启监听端口8888	,并打印日志
}

访问hello路径
head的内容

header["Accept"]=["text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"]
header["Sec-Fetch-Site"]=["none"]
header["Sec-Fetch-Dest"]=["document"]
header["Connection"]=["keep-alive"]
header["Sec-Ch-Ua-Platform"]=["\"Windows\""]
header["Sec-Fetch-User"]=["?1"]
header["X-Forwarded-For"]=["4.2.2.2"]
header["Accept-Language"]=["zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"]
header["User-Agent"]=["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 Edg/108.0.1462.42"]
header["Sec-Fetch-Mode"]=["navigate"]
header["Accept-Encoding"]=["gzip, deflate, br"]
header["Sec-Ch-Ua"]=["\"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"108\", \"Microsoft Edge\";v=\"108\""]
header["Sec-Ch-Ua-Mobile"]=["?0"]
header["Upgrade-Insecure-Requests"]=["1"]

我现在还不知道是什么意思,这里也做描述了

当我们访问了不存在的端口还是会执行"/"的函数(hell)

URL="/hell"

1.封装engine实现快捷添加路由

原理图

  1. 因为web的基础就是添加路由,然后支持用户的访问,对用户的访问做出正确的的响应,就是根据用户提交的路径执行相应的函数
    我们就用一个map来添加和映射出对应的返回
    map的string储存路径,Handlerfunc储存执行的方法
type Engin struct {
	router map[string]Handlerfunc
}
  1. 然后是添加路由的方法:向map中添加元素key为方法(get/post)路由value为hander函数(用户请求该路径的响应函数)
// 添加路由映射
func (engin *Engin) addroute(method string, pettern string, hander Handlerfunc) {
	key := method + "-" + pettern
	//
	engin.router[key] = hander
}

key为用户请求的方法(get,post)+路径;\

  1. get与post方法,添加两种不同的method的添加路由映射的方法
// 定义get方法
func (engin *Engin) GET(pattern string, handler Handlerfunc) {
	engin.addroute("GET", pattern, handler)
}

// 定义post方法;参数:一个是 路径字段,一个是响应函数
func (engin *Engin) POSE(pattern string, handler Handlerfunc) {
	// 调用add函数添加路由映射
	engin.addroute("POST", pattern, handler)
}

没什么可说的,就是method不同而已,其他都是一样的\

  1. 实现ServeHTTP接口,处理用户的 访问
    用户请求的执行函数,只要用户请求端口,无论什么路径都执行函数
    执行两步:\
  • 根据用户访问的method和url,映射出对应的处理函数(addrouter的时候添加好的)
  • 执行处理函数,如果没有对应的就输出404错误
// 定义serve函数
func (engin *Engin) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	// 查找映射字段
	key := req.Method + "-" + req.URL.Path
	if handler, ok := engin.router[key]; ok { //查找成功成功就执行hander函数
		handler(w, req)
	} else { //失败就返回404
		fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
	}
}
  1. 封装run函数
// 启动http服务;监听addr端口,并执行engin接口,当用户访问,执行servehttp函数
func (engin *Engin) Run(addr string) (err error) {
	return http.ListenAndServe(addr, engin)
}
  • 开启addr端口,
  • 启动服务:当用户访问的时候执行engine.ServeHTTP函数

封装2.context上下文

context作用:封装用户的请求与响应
原理图

这样context框架就搭好了,我们只需要添加路由就行了,其他的交给框架来操作,给我们节省时间.
重要的节点与函数,

type Context struct {
	// 数据写入端口,展示给用户
	Writer http.ResponseWriter
	// 用户请求的数据
	Req *http.Request
	// 路径字符串
	Path string
	//方法
	Method string
	//状态码404/200...
	StatusCode int
}

// 初始化context,请求时创建节点
func NewContext(w http.ResponseWriter, req *http.Request) *Context {
	return &Context{
		Writer: w,
		Req:    req,
		Path:   req.URL.Path,
		Method: req.Method,
	}
}
//请求执行函数
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	c := contexts.NewContext(w, req)
	engine.router.handle(c)
}


  1. 获取请求参数
//动态路由,tree加的动态路由
//获取路由参数 .例:/users/123
func (c *Context) Param(key string) string {
	value, _ := c.Params[key]
	return value
}
//获取表单数据 
func (c *Context) PostForm(key string) string {
	return c.Req.FormValue(key)
}
//获取query数据.例/login/?id=123
func (c *Context) Query(key string) string {
	return c.Req.URL.Query().Get(key)
}
  1. 构建响应
//输出响应信息
//设置header
func (c *Context) SetHeader(key string, value string) {
	c.Writer.Header().Set(key, value)
}

func (c *Context) String(code int, format string, values ...interface{}) {
	c.SetHeader("Content-Type", "text/plain")
	c.Status(code)
	c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
}

func (c *Context) JSON(code int, obj interface{}) {
	c.SetHeader("Content-Type", "application/json")
	c.Status(code)
	encoder := json.NewEncoder(c.Writer)
	if err := encoder.Encode(obj); err != nil {
		http.Error(c.Writer, err.Error(), 500)
	}
}

func (c *Context) Data(code int, data []byte) {
	c.Status(code)
	c.Writer.Write(data)
}
//name为文件名
func (c *Context) HTML(code int, name string, data interface{}) {
	c.SetHeader("Content-Type", "text/html")
	c.Status(code)
	if err := c.engine.htmlTemplates.ExecuteTemplate(c.Writer, name, data); err != nil {
		c.Fail(500, err.Error())
		fmt.Fprintf(c.Writer, "404 NOT FOUND: %s\n", c.Req.URL)
	}
}
func (c *Context) Fail(StatusCode int, err string) {
	http.Error(c.Writer, err, StatusCode)
}

封装tree路由树

目的实现动态路由解析
我们使用前缀树来对路由进行储存,实现动态路由的解析.
定义通配符规则:我们这里使用:和*作为通配符
比如:我们添加的路由是user/:id/*name
用户访问的是user/123/小明 或者 user/456/小红 都是是可以正常访问的,就是如果该节点是通配符就可以继续查找子节点直到找到最终的节点或者找不到节点,讲节点的pattern拿去hander map中映射函数,再用函数输出响应

节点定义

type Node struct {
	Pattern  string  // 准确的 路径
	Part     string  // 路径的尾(当前的)
	Children []*Node // 子节点
	IsWild   bool    // 是否为通配符
}
type Rrouter struct {
	Roots    map[string]*tree.Node
	Handlers map[string]HandlerFunc
}
func (n *Node) MatchChild(part string) *Node {
	for _, child := range n.Children {
		if child.Part == part || child.IsWild {
			return child
		}
	}
	return nil
}

// 查找返回与所有节点;查找n中part字段,返回节点数组(与MatchChild的区别:本函数返回所有匹配的节点而MatchChild返回第一个匹配的节点)
func (n *Node) MatchChildren(part string) []*Node {
	nodes := make([]*Node, 0)          //建立一个新节点
	for _, child := range n.Children { //遍历n中是否有panrt的相对或者绝对路径
		if child.Part == part || child.IsWild { //如果存在或者精确匹配
			nodes = append(nodes, child) //节点赋值
		}
	}
	return nodes //返回节点数组,没有就是空节点
}

// 如果当前处理的 URL 路径部分已经到了最后一部分(即 len(parts) == height),那么说明已经找到了该路由规则对应的叶子节点,将该叶子节点的模式设置为该规则对应的处理器函数 pattern,然后直接返回。

// 如果当前节点的子节点中有一个子节点的 part 值和当前处理的 URL 路径部分相等,或者是一个通配符(即 child.part == part || child.isWild),那么说明这个子节点可以用于匹配当前 URL 路径部分,就把 height 加一,递归调用 Insert 方法,将处理的 URL 路径部分的索引值向后移动一个位置。

// 如果找不到符合条件的子节点,说明当前节点的子节点中没有一个子节点能够匹配当前处理的 URL 路径部分,就创建一个新的子节点,将该子节点插入到当前节点的子节点列表中,然后递归调用 Insert 方法。

// 插入节点:pattern 为路由,路由的parts为字符串切片,,hight为树的深度.
func (n *Node) Insert(pattern string, parts []string, height int) {
	if len(parts) == height {
		n.Pattern = pattern
		return
	}

	part := parts[height]
	child := n.MatchChild(part)
	if child == nil {
		child = &Node{Part: part, IsWild: part[0] == ':' || part[0] == '*'}
		n.Children = append(n.Children, child)
	}
	child.Insert(pattern, parts, height+1)
}

// 如果当前处理的 URL 路径部分已经到了最后一部分(即 len(parts) == height),或者当前节点是一个以 * 开头的通配符节点,那么说明已经找到了匹配的叶子节点,如果该叶子节点的模式不为空,就返回该叶子节点,否则返回 nil。

// 如果当前节点的子节点中有一个子节点的 part 值和当前处理的 URL 路径部分相等,或者是一个通配符(即 child.part == part || child.isWild),那么说明这个子节点可以用于匹配当前 URL 路径部分,就把 height 加一,递归调用 Search 方法,将处理的 URL 路径部分的索引值向后移动一个位置。

// 如果找不到符合条件的子节点,说明当前节点的子节点中没有一个子节点能够匹配当前处理的 URL 路径部分,就返回 nil。

func (n *Node) Search(parts []string, height int) *Node {
	if len(parts) == height || strings.HasPrefix(n.Part, "*") {
		if n.Pattern == "" {
			return nil
		}
		return n
	}

	part := parts[height]
	children := n.MatchChildren(part)

	for _, child := range children {
		result := child.Search(parts, height+1)
		if result != nil {
			return result
		}
	}

	return nil
}

//parts 是一个字符串切片,表示一个 URL 路径经过分割后的每一个部分。比如,对于一个 URL 路径 /users/:id/books/*title,经过分割后的 parts 内容应该是 []string{"users", ":id", "books", "*title"}。

分组封装group与中间件封装

比如说我们要添加两个路由分别是user/id与user/name
两条路径都要输出一个hello world
除此之外访问user/id还要输出this is id
访问user/name还要输出this is name
这时我们可定义一个分组的类

type RouterGroup struct {
	Prefix      string        //父元素的字符串(分组名:例如/user)
	Middlewares []HandlerFunc //中间件函数
	Parent      *RouterGroup  //父元素的节点
	Engine      *Engine       //所有的分组公用一个engine以实现所有的函数的接口
}

Prefix储存分组名,就是路径相同的部分
Middlewares储存中间件函数,就是访问路径就要执行的函数,比如刚刚的访问包括/user就会执行hello world,就可以将hello world函数添加到中间件函数列表中.

而context也会增加一个属性就是handers,它是一个handerfunc的函数列表,

type Context struct {
	// origin objects
	Writer http.ResponseWriter //Writer: 一个 http.ResponseWriter 类型的对象,用于将响应内容写入 HTTP 响应体。
	Req    *http.Request       //Req: 一个 *http.Request 类型的指针,表示客户端发起的 HTTP 请求。
	// request info
	Path   string            //一个字符串,表示请求的 URL 路径。
	Method string            //一个字符串,表示请求的 HTTP 方法。get/post...
	Params map[string]string //一个字符串到字符串的映射,表示 URL 查询参数或表单数据。
	// response info
	StatusCode int //一个整数,表示 HTTP 响应状态码。
	// middleware
	Handlers []HandlerFunc //handlers: 一个 HandlerFunc 类型的切片,表示当前请求需要经过的中间件函数列表。
	Index    int           //index: 一个整数,表示当前执行到的中间件函数在 handlers 中的下标。可以理解为handers的下标
	engine   *Engine
}

用于储存执行函数,就是当用户访问路径正确后handers=group.Middlewares(中间件函数)+route.hander(路径的执行函数)
比如刚刚的例子:handers=group.Middlewares(“/user"输出hello world)+route.hander(”/user/id"映射出的输出this is id)
最后依次执行handers中所有的函数

原理如图
黑色表示添加路由路径,红色表示请求响应路径,蓝色表示添加路由与响应都走的路径 只是一个是插入,一个是查找.
原理图

type RouterGroup struct {
	Prefix      string        //父元素的字符串(分组名;例如/user)
	Middlewares []HandlerFunc //中间件函数
	Parent      *RouterGroup  //父元素的节点
	Engine      *Engine       //所有的分组公用一个engine以实现所有的函数的接口
}
type Engine struct {
	*RouterGroup
	Router        *Rrouter           //
	Groups        []*RouterGroup     //
	htmlTemplates *template.Template //
	funcMap       template.FuncMap   //
}

func (group *RouterGroup) Use(middlewares ...HandlerFunc) {
	group.Middlewares = append(group.Middlewares, middlewares...)
}
// 初始化一个engine节点用于实现整个框架的功能
func New() *Engine {
	engine := &Engine{Router: NewRouter()}
	engine.RouterGroup = &RouterGroup{Engine: engine}
	engine.Groups = []*RouterGroup{engine.RouterGroup}
	return engine
}

// 在当前路由分组下创建一个子分组,可以通过传入前缀字符串来指定子分组的前缀,该函数会返回一个新的 RouterGroup 对象,表示创建的子分组。在创建子分组时,需要指定父分组和引擎节点。
func (group *RouterGroup) Group(prefix string) *RouterGroup {
	engine := group.Engine
	newGroup := &RouterGroup{
		Prefix: group.Prefix + prefix,
		Parent: group,
		Engine: engine,
	}
	engine.Groups = append(engine.Groups, newGroup)
	return newGroup
}

中间件创建于响应的执行



func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	var middlewares []HandlerFunc
	// 这行代码使用了Go语言标准库中的strings包的HasPrefix函数来判断req.URL.Path是否以group.Prefix为前缀。
	for _, group := range engine.Groups {
		if strings.HasPrefix(req.URL.Path, group.Prefix) {
			middlewares = append(middlewares, group.Middlewares...)
		}
	}
	c := NewContext(w, req)
	c.Handlers = middlewares
	c.engine = engine
	engine.Router.Handle(c)
}
// 在当前路由分组下创建一个子分组,可以通过传入前缀字符串来指定子分组的前缀,该函数会返回一个新的 RouterGroup 对象,表示创建的子分组。在创建子分组时,需要指定父分组和引擎节点。
func (group *RouterGroup) Group(prefix string) *RouterGroup {
	engine := group.Engine
	newGroup := &RouterGroup{
		Prefix: group.Prefix + prefix,
		Parent: group,
		Engine: engine,
	}
	engine.Groups = append(engine.Groups, newGroup)
	return newGroup
}

文件解析封装

// createStaticHandler() 函数创建了一个处理静态文件请求的 HandlerFunc。
// 它接收两个参数:相对路径 relativePath 和文件系统 fs。该函数返回另一个 HandlerFunc,该函数负责检查请求的文件是否存在并且是否可以访问
// ,如果可以访问,就使用 http.FileServer 将文件传输给客户端。
func (group *RouterGroup) createStaticHandler(relativePath string, fs http.FileSystem) HandlerFunc {
	absolutePath := path.Join(group.Prefix, relativePath)
	fileServer := http.StripPrefix(absolutePath, http.FileServer(fs))
	return func(c *Context) {
		file := c.Param("filepath")
		// Check if file exists and/or if we have permission to access it
		if _, err := fs.Open(file); err != nil {
			c.Status(http.StatusNotFound)
			return
		}

		fileServer.ServeHTTP(c.Writer, c.Req)
	}
}

// Static() 函数是将静态文件服务器注册到路由中的函数。
// 它接收两个参数:相对路径 relativePath 和静态文件的根目录 root。它调用 createStaticHandler() 函数创建一个 HandlerFunc,并将其注册到路由器中,以便在请求 URL 匹配特定模式时调用。
func (group *RouterGroup) Static(relativePath string, root string) {
	handler := group.createStaticHandler(relativePath, http.Dir(root))
	urlPattern := path.Join(relativePath, "/*filepath")
	// Register GET handlers
	group.GET(urlPattern, handler)
}




// 设置 HTML 模板中使用的函数,接收一个 template.FuncMap 类型的参数,将其存储到 engine.funcMap 中。
func (engine *Engine) SetFuncMap(funcMap template.FuncMap) {
	engine.funcMap = funcMap
}

// 用于加载 HTML 模板文件,接收一个字符串类型的参数 pattern,表示要加载的模板文件的路径。
// 该方法首先通过 template.New("") 创建了一个空的模板,然后通过 engine.funcMap 将模板中使用的函数添加到模板中,
// 最后调用 template.ParseGlob(pattern) 加载指定路径下的所有模板文件,并通过 template.Must() 将加载的模板文件包装成一个不可变的 template.Template 类型,
// 并将其存储到 engine.htmlTemplates 中。这样,在程序运行过程中,就可以通过 engine.htmlTemplates 调用 HTML 模板了。
func (engine *Engine) LoadHTMLGlob(pattern string) {
	engine.htmlTemplates = template.Must(template.New("").Funcs(engine.funcMap).ParseGlob(pattern))
}

封装错误处理

  1. Recovery中间件函数,用于捕获panic并打印详细的错误日志和堆栈信息。
  2. trace函数用于打印错误信息和堆栈跟踪
// print stack trace for debug
func trace(message string) string {
//runtime.Callers函数来获取当前goroutine的调用堆栈,并将其存储在一个长度为32的uintptr数组中,最多存储32个调用者的地址。
	var pcs [32]uintptr
	n := runtime.Callers(3, pcs[:]) //该函数的返回值n表示实际存储在数组中的调用者数目。
//runtime.Callers函数的第一个参数指定从哪个调用者开始跟踪,
//通常传入3,以跳过当前函数trace、调用trace的函数log.Printf和Recovery函数。

	var str strings.Builder
	//错误信息、堆栈跟踪等信息写入该字符串中。
	str.WriteString(message + "\nTraceback:")
	for _, pc := range pcs[:n] {//for循环遍历pcs[:n]数组,获取每个函数调用者的信息,并将其写入str中
		fn := runtime.FuncForPC(pc)
		file, line := fn.FileLine(pc)
		str.WriteString(fmt.Sprintf("\n\t%s:%d", file, line))
	}
	return str.String()
}

func Recovery() HandlerFunc {
	return func(c *Context) {
		defer func() {
		//该代码中的recover函数用于恢复panic,并将其转换为字符串类型的message。然后,使用log.Printf函数打印错误信息和堆栈跟踪信息,并使用c.Fail函数向客户端发送一个HTTP错误响应。
			if err := recover(); err != nil {
				message := fmt.Sprintf("%s", err)
				log.Printf("%s\n\n", trace(message))
				c.Fail(http.StatusInternalServerError, "Internal Server Error")
				fmt.Fprintf(c.Writer, "404 NOT FOUND: %s\n", c.Req.URL)
			}
		}()

		c.Next()
	}
}

http://localhost:9999/http://localhost:9999/static/index.html

  1. 单元测试(

  2. 集成测试

测试代码
func main() {
	r := gee6.New()
	r.Use(gee6.Logger())
	r.SetFuncMap(template.FuncMap{
		"FormatAsDate": FormatAsDate,
	})
	r.LoadHTMLGlob("templates/*")
	r.Static("/assets", "./static")

	stu1 := &student{Name: "Geektutu", Age: 20}
	stu2 := &student{Name: "Jack", Age: 22}
	//测试string函数
	r.GET("/", func(c *gee6.Context) {
		c.String(200, "hello world")
	})
	r.GET("/students", func(c *gee6.Context) {
		c.HTML(http.StatusOK, "arr.tmpl", gee6.H{
			"title":  "gee",
			"stuArr": [2]*student{stu1, stu2},
		})
	})
	// 测试html数据响应
	r.GET("/date", func(c *gee6.Context) {
		c.HTML(http.StatusOK, "custom_func.tmpl", gee6.H{
			"title": "gee6",
			"now":   time.Date(2023, 3, 29, 0, 0, 0, 0, time.UTC),
		})
	})
	//测试PostForm函数
	r.POST("/login/name", func(ctx *gee6.Context) {
		ctx.String(200, "hello:"+ctx.PostForm("name"))
	})
	//测试Query函数
	r.GET("/login", func(ctx *gee6.Context) {
		ctx.String(200, "id=%s, name=%s", ctx.Query("id"), ctx.Query("name"))
	})
	r.GET("/users/:id", func(c *gee6.Context) {
		id := c.Param("id")
		c.String(200, "id=%s", id)
	})
	r.Run(":8888")
}

get请求可以由服务器路由发送

post请求,由apifox发送

加载html文件并渲染
响应数据

响应结果

  1. 压力测试(并发测试,测试性能)//完成
    我的gee6请求"/"1000*100次压力测试结果
Document Path:          /
Document Length:        11 bytes

Concurrency Level:      100
Time taken for tests:   0.118 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      113000 bytes
HTML transferred:       11000 bytes
Requests per second:    8502.39 [#/sec] (mean)
Time per request:       11.761 [ms] (mean)
Time per request:       0.118 [ms] (mean, across all concurrent requests)
Transfer rate:          938.25 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    2   0.9      2      10
Processing:     1    9  16.9      3      62
Waiting:        0    8  16.3      2      59
Total:          2   11  16.7      5      63

Percentage of the requests served within a certain time (ms)
  50%      5
  66%      6
  75%      6
  80%      7
  90%     58
  95%     61
  98%     62
  99%     62
 100%     63 (longest request)

gin的请求"/"1000*100次压力测试结果

Document Length:        13 bytes

Concurrency Level:      100
Time taken for tests:   0.127 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      130000 bytes
HTML transferred:       13000 bytes
Requests per second:    7893.53 [#/sec] (mean)
Time per request:       12.669 [ms] (mean)
Time per request:       0.127 [ms] (mean, across all concurrent requests)
Transfer rate:          1002.11 [Kbytes/sec] received

Connection Times (ms)
             min  mean[+/-sd] median   max
Connect:        0    2   1.3      1      13
Processing:     1   10  18.9      3      69
Waiting:        0    9  18.7      2      68
Total:          2   11  19.1      4      72

Percentage of the requests served within a certain time (ms)
 50%      4
 66%      6
 75%      7
 80%      9
 90%     66
 95%     69
 98%     69
 99%     69
100%     72 (longest request)

稍微快一点点,总体差不多的

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值