之前在Gin中已经说到, Gin比Martini的效率高好多耶, 究其原因是因为使用了httprouter这个路由框架, httprouter的git地址是: httprouter源码. 今天稍微看了下httprouter的 实现原理, 其实就是使用了一个radix tree(前缀树)来管理请求的URL, 下面具体看看httprouter原理.

###1. httprouter基本结构

httprouter中, 对于每种方法都有一颗tree来管理, 例如所有的GET方法对应的请求会有一颗tree管理, 所有的POST同样如此. OK, 那首先看一下 这个router结构体长啥样:

  1. type Router struct {
  2. // 这个radix tree是最重要的结构
  3. // 按照method将所有的方法分开, 然后每个method下面都是一个radix tree
  4. trees map[string]*node
  5.  
  6. // Enables automatic redirection if the current route can't be matched but a
  7. // handler for the path with (without) the trailing slash exists.
  8. // For example if /foo/ is requested but a route only exists for /foo, the
  9. // client is redirected to /foo with http status code 301 for GET requests
  10. // and 307 for all other request methods.
  11. // 当/foo/没有匹配到的时候, 是否允许重定向到/foo路径
  12. RedirectTrailingSlash bool
  13.  
  14. // If enabled, the router tries to fix the current request path, if no
  15. // handle is registered for it.
  16. // First superfluous path elements like ../ or // are removed.
  17. // Afterwards the router does a case-insensitive lookup of the cleaned path.
  18. // If a handle can be found for this route, the router makes a redirection
  19. // to the corrected path with status code 301 for GET requests and 307 for
  20. // all other request methods.
  21. // For example /FOO and /..//Foo could be redirected to /foo.
  22. // RedirectTrailingSlash is independent of this option.
  23. // 是否允许修正路径
  24. RedirectFixedPath bool
  25.  
  26. // If enabled, the router checks if another method is allowed for the
  27. // current route, if the current request can not be routed.
  28. // If this is the case, the request is answered with 'Method Not Allowed'
  29. // and HTTP status code 405.
  30. // If no other Method is allowed, the request is delegated to the NotFound
  31. // handler.
  32. // 如果当前无法匹配, 那么检查是否有其他方法能match当前的路由
  33. HandleMethodNotAllowed bool
  34.  
  35. // If enabled, the router automatically replies to OPTIONS requests.
  36. // Custom OPTIONS handlers take priority over automatic replies.
  37. // 是否允许路由自动匹配options, 注意: 手动匹配的option优先级高于自动匹配
  38. HandleOPTIONS bool
  39.  
  40. // Configurable http.Handler which is called when no matching route is
  41. // found. If it is not set, http.NotFound is used.
  42. // 当no match的时候, 执行这个handler. 如果没有配置,那么返回NoFound
  43. NotFound http.Handler
  44.  
  45. // Configurable http.Handler which is called when a request
  46. // cannot be routed and HandleMethodNotAllowed is true.
  47. // If it is not set, http.Error with http.StatusMethodNotAllowed is used.
  48. // The "Allow" header with allowed request methods is set before the handler
  49. // is called.
  50. // 当no natch并且HandleMethodNotAllowed=true的时候,这个函数被使用
  51. MethodNotAllowed http.Handler
  52.  
  53. // Function to handle panics recovered from http handlers.
  54. // It should be used to generate a error page and return the http error code
  55. // 500 (Internal Server Error).
  56. // The handler can be used to keep your server from crashing because of
  57. // unrecovered panics.
  58. // panic函数
  59. PanicHandler func(http.ResponseWriter, *http.Request, interface{})
  60. }

上面的结构中, trees map[string]*node代表的一个森林, 里面有一颗GET tree, POST tree… 
对应到每棵tree上的结构, 其实就是前缀树结构, 从github上盗了一张图:

假设上图是一颗GET tree, 那么其实是注册了下面这些GET方法:

  1. GET("/search/", func1)
  2. GET("/support/", func2)
  3. GET("/blog/:post/", func3)
  4. GET("/about-us/", func4)
  5. GET("/about-us/team/", func5)
  6. GET("/contact/", func6)

注意看到, tree的组成是根据前缀来划分的, 例如search和support存在共同前缀s, 所以将s作为单独的parent节点. 但是注意这个s节点是没有handle的. 对应/about-us/和/about-us/team/, 前者是后者的parent, 但是前者也是有 handle的, 这一点还是有点区别的. 
总体来说, 创建节点和查询都是按照tree的层层查找来进行处理的. 下面顺便解释一下tree node的结构:

  1. type node struct {
  2. // 保存这个节点上的URL路径
  3. // 例如上图中的search和support, 共同的parent节点的path="s"
  4. // 后面两个节点的path分别是"earch"和"upport"
  5. path string
  6. // 判断当前节点路径是不是参数节点, 例如上图的:post部分就是wildChild节点
  7. wildChild bool
  8. // 节点类型包括static, root, param, catchAll
  9. // static: 静态节点, 例如上面分裂出来作为parent的s
  10. // root: 如果插入的节点是第一个, 那么是root节点
  11. // catchAll: 有*匹配的节点
  12. // param: 除上面外的节点
  13. nType nodeType
  14. // 记录路径上最大参数个数
  15. maxParams uint8
  16. // 和children[]对应, 保存的是分裂的分支的第一个字符
  17. // 例如search和support, 那么s节点的indices对应的"eu"
  18. // 代表有两个分支, 分支的首字母分别是e和u
  19. indices string
  20. // 保存孩子节点
  21. children []*node
  22. // 当前节点的处理函数
  23. handle Handle
  24. // 优先级, 看起来没什么卵用的样子@_@
  25. priority uint32
  26. }

###2. 建树过程

建树过程主要涉及到两个函数: addRoute和insertChild, 下面主要看看这两个函数: 
首先是addRoute函数:

  1. // addRoute adds a node with the given handle to the path.
  2. // Not concurrency-safe!
  3. // 向tree中增加节点
  4. func (n *node) addRoute(path string, handle Handle) {
  5. fullPath := path
  6. n.priority++
  7. numParams := countParams(path)
  8.  
  9. // non-empty tree
  10. // 如果之前这个Method tree中已经存在节点了
  11. if len(n.path) > 0 || len(n.children) > 0 {
  12. walk:
  13. for {
  14. // Update maxParams of the current node
  15. // 更新当前node的最大参数个数
  16. if numParams > n.maxParams {
  17. n.maxParams = numParams
  18. }
  19.  
  20. // Find the longest common prefix.
  21. // This also implies that the common prefix contains no ':' or '*'
  22. // since the existing key can't contain those chars.
  23. // 找到最长公共前缀
  24. i := 0
  25. max := min(len(path), len(n.path))
  26. // 匹配相同的字符
  27. for i < max && path[i] == n.path[i] {
  28. i++
  29. }
  30.  
  31. // Split edge
  32. // 说明前面有一段是匹配的, 例如之前为:/search,现在来了一个/support
  33. // 那么会将/s拿出来作为parent节点, 将child节点变成earch和upport
  34. if i < len(n.path) {
  35. // 将原本路径的i后半部分作为前半部分的child节点
  36. child := node{
  37. path: n.path[i:],
  38. wildChild: n.wildChild,
  39. nType: static,
  40. indices: n.indices,
  41. children: n.children,
  42. handle: n.handle,
  43. priority: n.priority - 1,
  44. }
  45.  
  46. // Update maxParams (max of all children)
  47. // 更新最大参数个数
  48. for i := range child.children {
  49. if child.children[i].maxParams > child.maxParams {
  50. child.maxParams = child.children[i].maxParams
  51. }
  52. }
  53. // 当前节点的孩子节点变成刚刚分出来的这个后半部分节点
  54. n.children = []*node{&child}
  55. // []byte for proper unicode char conversion, see #65
  56. n.indices = string([]byte{n.path[i]})
  57. // 路径变成前i半部分path
  58. n.path = path[:i]
  59. n.handle = nil
  60. n.wildChild = false
  61. }
  62.  
  63. // Make new node a child of this node
  64. // 同时, 将新来的这个节点插入新的parent节点中当做孩子节点
  65. if i < len(path) {
  66. // i的后半部分作为路径, 即上面例子support中的upport
  67. path = path[i:]
  68.  
  69. // 如果n是参数节点(包含:或者*)
  70. if n.wildChild {
  71. n = n.children[0]
  72. n.priority++
  73.  
  74. // Update maxParams of the child node
  75. if numParams > n.maxParams {
  76. n.maxParams = numParams
  77. }
  78. numParams--
  79.  
  80. // Check if the wildcard matches
  81. // 例如: /blog/:ppp 和 /blog/:ppppppp, 需要检查更长的通配符
  82. if len(path) >= len(n.path) && n.path == path[:len(n.path)] {
  83. // check for longer wildcard, e.g. :name and :names
  84. if len(n.path) >= len(path) || path[len(n.path)] == '/' {
  85. continue walk
  86. }
  87. }
  88.  
  89. panic("path segment '" + path +
  90. "' conflicts with existing wildcard '" + n.path +
  91. "' in path '" + fullPath + "'")
  92. }
  93.  
  94. c := path[0]
  95.  
  96. // slash after param
  97. if n.nType == param && c == '/' && len(n.children) == 1 {
  98. n = n.children[0]
  99. n.priority++
  100. continue walk
  101. }
  102.  
  103. // Check if a child with the next path byte exists
  104. // 检查路径是否已经存在, 例如search和support第一个字符相同
  105. for i := 0; i < len(n.indices); i++ {
  106. // 找到第一个匹配的字符
  107. if c == n.indices[i] {
  108. i = n.incrementChildPrio(i)
  109. n = n.children[i]
  110. continue walk
  111. }
  112. }
  113.  
  114. // Otherwise insert it
  115. // new一个node
  116. if c != ':' && c != '*' {
  117. // []byte for proper unicode char conversion, see #65
  118. // 记录第一个字符,并放在indices中
  119. n.indices += string([]byte{c})
  120. child := &node{
  121. maxParams: numParams,
  122. }
  123. // 增加孩子节点
  124. n.children = append(n.children, child)
  125. n.incrementChildPrio(len(n.indices) - 1)
  126. n = child
  127. }
  128. // 插入节点
  129. n.insertChild(numParams, path, fullPath, handle)
  130. return
  131.  
  132. // 说明是相同的路径,仅仅需要将handle替换就OK
  133. // 如果是nil那么说明取消这个handle, 不是空不允许
  134. } else if i == len(path) { // Make node a (in-path) leaf
  135. if n.handle != nil {
  136. panic("a handle is already registered for path '" + fullPath + "'")
  137. }
  138. n.handle = handle
  139. }
  140. return
  141. }
  142. } else { // Empty tree
  143. // 如果是空树, 那么插入节点
  144. n.insertChild(numParams, path, fullPath, handle)
  145. // 节点的种类是root
  146. n.nType = root
  147. }
  148. }

上面函数的目的是找到插入节点的位置, 需要主要如果存在common前缀, 那么需要将节点进行分裂, 然后再插入child节点. 再看一些insertChild函数:

  1. // 插入节点函数
  2. // @1: 参数个数
  3. // @2: 输入路径
  4. // @3: 完整路径
  5. // @4: 路径关联函数
  6. func (n *node) insertChild(numParams uint8, path, fullPath string, handle Handle) {
  7. var offset int // already handled bytes of the path
  8.  
  9. // find prefix until first wildcard (beginning with ':'' or '*'')
  10. // 找到前缀, 直到遇到第一个wildcard匹配的参数
  11. for i, max := 0, len(path); numParams > 0; i++ {
  12. c := path[i]
  13. if c != ':' && c != '*' {
  14. continue
  15. }
  16.  
  17. // find wildcard end (either '/' or path end)
  18. end := i + 1
  19. // 下面判断:或者*之后不能再有*或者:, 这样是属于参数错误
  20. // 除非到了下一个/XXX
  21. for end < max && path[end] != '/' {
  22. switch path[end] {
  23. // the wildcard name must not contain ':' and '*'
  24. case ':', '*':
  25. panic("only one wildcard per path segment is allowed, has: '" +
  26. path[i:] + "' in path '" + fullPath + "'")
  27. default:
  28. end++
  29. }
  30. }
  31.  
  32. // check if this Node existing children which would be
  33. // unreachable if we insert the wildcard here
  34. if len(n.children) > 0 {
  35. panic("wildcard route '" + path[i:end] +
  36. "' conflicts with existing children in path '" + fullPath + "'")
  37. }
  38.  
  39. // check if the wildcard has a name
  40. // 下面的判断说明只有:或者*,没有name,这也是不合法的
  41. if end-i < 2 {
  42. panic("wildcards must be named with a non-empty name in path '" + fullPath + "'")
  43. }
  44.  
  45. // 如果是':',那么匹配一个参数
  46. if c == ':' { // param
  47. // split path at the beginning of the wildcard
  48. // 节点path是参数前面那么一段, offset代表已经处理了多少path中的字符
  49. if i > 0 {
  50. n.path = path[offset:i]
  51. offset = i
  52. }
  53. // 构造一个child
  54. child := &node{
  55. nType: param,
  56. maxParams: numParams,
  57. }
  58. n.children = []*node{child}
  59. n.wildChild = true
  60. // 下次的循环就是这个新的child节点了
  61. n = child
  62. // 最长匹配, 所以下面节点的优先级++
  63. n.priority++
  64. numParams--
  65.  
  66. // if the path doesn't end with the wildcard, then there
  67. // will be another non-wildcard subpath starting with '/'
  68. if end < max {
  69. n.path = path[offset:end]
  70. offset = end
  71.  
  72. child := &node{
  73. maxParams: numParams,
  74. priority: 1,
  75. }
  76. n.children = []*node{child}
  77. n = child
  78. }
  79.  
  80. } else { // catchAll
  81. // *匹配所有参数
  82. if end != max || numParams > 1 {
  83. panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'")
  84. }
  85.  
  86. if len(n.path) > 0 && n.path[len(n.path)-1] == '/' {
  87. panic("catch-all conflicts with existing handle for the path segment root in path '" + fullPath + "'")
  88. }
  89.  
  90. // currently fixed width 1 for '/'
  91. i--
  92. if path[i] != '/' {
  93. panic("no / before catch-all in path '" + fullPath + "'")
  94. }
  95.  
  96. n.path = path[offset:i]
  97.  
  98. // first node: catchAll node with empty path
  99. child := &node{
  100. wildChild: true,
  101. nType: catchAll,
  102. maxParams: 1,
  103. }
  104. n.children = []*node{child}
  105. n.indices = string(path[i])
  106. n = child
  107. n.priority++
  108.  
  109. // second node: node holding the variable
  110. child = &node{
  111. path: path[i:],
  112. nType: catchAll,
  113. maxParams: 1,
  114. handle: handle,
  115. priority: 1,
  116. }
  117. n.children = []*node{child}
  118.  
  119. return
  120. }
  121. }
  122.  
  123. // insert remaining path part and handle to the leaf
  124. n.path = path[offset:]
  125. n.handle = handle
  126. }

insertChild函数是根据path本身进行分割, 将’/’分开的部分分别作为节点保存, 形成一棵树结构. 注意参数匹配中的’:’和’*‘的区别, 前者是匹配一个字段, 后者是匹配后面所有的路径. 具体的细节, 请查看代码中的注释.

###3. 查找path过程

这个过程其实就是匹配每个child的path, walk知道path最后.

  1. // Returns the handle registered with the given path (key). The values of
  2. // wildcards are saved to a map.
  3. // If no handle can be found, a TSR (trailing slash redirect) recommendation is
  4. // made if a handle exists with an extra (without the) trailing slash for the
  5. // given path.
  6. func (n *node) getValue(path string) (handle Handle, p Params, tsr bool) {
  7. walk: // outer loop for walking the tree
  8. for {
  9. // 意思是如果还没有走到路径end
  10. if len(path) > len(n.path) {
  11. // 前面一段必须和当前节点的path一样才OK
  12. if path[:len(n.path)] == n.path {
  13. path = path[len(n.path):]
  14. // If this node does not have a wildcard (param or catchAll)
  15. // child, we can just look up the next child node and continue
  16. // to walk down the tree
  17. // 如果不是参数节点, 那么根据分支walk到下一个节点就OK
  18. if !n.wildChild {
  19. c := path[0]
  20. // 找到分支的第一个字符=>找到child
  21. for i := 0; i < len(n.indices); i++ {
  22. if c == n.indices[i] {
  23. n = n.children[i]
  24. continue walk
  25. }
  26. }
  27.  
  28. // Nothing found.
  29. // We can recommend to redirect to the same URL without a
  30. // trailing slash if a leaf exists for that path.
  31. tsr = (path == "/" && n.handle != nil)
  32. return
  33.  
  34. }
  35.  
  36. // handle wildcard child
  37. // 下面处理通配符参数节点
  38. n = n.children[0]
  39. switch n.nType {
  40. // 如果是普通':'节点, 那么找到/或者path end, 获得参数
  41. case param:
  42. // find param end (either '/' or path end)
  43. end := 0
  44. for end < len(path) && path[end] != '/' {
  45. end++
  46. }
  47. // 获取参数
  48. // save param value
  49. if p == nil {
  50. // lazy allocation
  51. p = make(Params, 0, n.maxParams)
  52. }
  53. i := len(p)
  54. p = p[:i+1] // expand slice within preallocated capacity
  55. // 获取key和value
  56. p[i].Key = n.path[1:]
  57. p[i].Value = path[:end]
  58.  
  59. // we need to go deeper!
  60. // 如果参数还没处理完, 继续walk
  61. if end < len(path) {
  62. if len(n.children) > 0 {
  63. path = path[end:]
  64. n = n.children[0]
  65. continue walk
  66. }
  67.  
  68. // ... but we can't
  69. tsr = (len(path) == end+1)
  70. return
  71. }
  72. // 否则获得handle返回就OK
  73. if handle = n.handle; handle != nil {
  74. return
  75. } else if len(n.children) == 1 {
  76. // No handle found. Check if a handle for this path + a
  77. // trailing slash exists for TSR recommendation
  78. n = n.children[0]
  79. tsr = (n.path == "/" && n.handle != nil)
  80. }
  81.  
  82. return
  83.  
  84. case catchAll:
  85. // save param value
  86. if p == nil {
  87. // lazy allocation
  88. p = make(Params, 0, n.maxParams)
  89. }
  90. i := len(p)
  91. p = p[:i+1] // expand slice within preallocated capacity
  92. p[i].Key = n.path[2:]
  93. p[i].Value = path
  94.  
  95. handle = n.handle
  96. return
  97.  
  98. default:
  99. panic("invalid node type")
  100. }
  101. }
  102. // 走到路径end
  103. } else if path == n.path {
  104. // We should have reached the node containing the handle.
  105. // Check if this node has a handle registered.
  106. // 判断这个路径节点是都存在handle, 如果存在, 那么就可以直接返回了.
  107. if handle = n.handle; handle != nil {
  108. return
  109. }
  110. // 下面判断是不是需要进入重定向
  111. if path == "/" && n.wildChild && n.nType != root {
  112. tsr = true
  113. return
  114. }
  115.  
  116. // No handle found. Check if a handle for this path + a
  117. // trailing slash exists for trailing slash recommendation
  118. // 判断path+'/'是否存在handle
  119. for i := 0; i < len(n.indices); i++ {
  120. if n.indices[i] == '/' {
  121. n = n.children[i]
  122. tsr = (len(n.path) == 1 && n.handle != nil) ||
  123. (n.nType == catchAll && n.children[0].handle != nil)
  124. return
  125. }
  126. }
  127.  
  128. return
  129. }
  130.  
  131. // Nothing found. We can recommend to redirect to the same URL with an
  132. // extra trailing slash if a leaf exists for that path
  133. tsr = (path == "/") ||
  134. (len(n.path) == len(path)+1 && n.path[len(path)] == '/' &&
  135. path == n.path[:len(n.path)-1] && n.handle != nil)
  136. return
  137. }
  138. }

  

httprouter框架 (Gin使用的路由框架)的更多相关文章

  1. golangWEB框架gin学习之路由群组

    原文地址:http://www.niu12.com/article/42 package main import ( "github.com/gin-gonic/gin" &quo ...

  2. Gin框架系列02:路由与参数

    回顾 上一节我们用Gin框架快速搭建了一个GET请求的接口,今天来学习路由和参数的获取. 请求动词 熟悉RESTful的同学应该知道,RESTful是网络应用程序的一种设计风格和开发方式,每一个URI ...

  3. Golang 微框架 Gin 简介

    框架一直是敏捷开发中的利器,能让开发者很快的上手并做出应用,甚至有的时候,脱离了框架,一些开发者都不会写程序了.成长总不会一蹴而就,从写出程序获取成就感,再到精通框架,快速构造应用,当这些方面都得心应 ...

  4. Go语言web框架 gin

    Go语言web框架 GIN gin是go语言环境下的一个web框架, 它类似于Martini, 官方声称它比Martini有更好的性能, 比Martini快40倍, Ohhhh….看着不错的样子, 所 ...

  5. go框架gin的使用

    我们在用http的时候一般都会用一些web框架来进行开发,gin就是这样的一个框架,它有哪些特点呢 一:gin特点 1.性能优秀2.基于官方的net/http的有限封装3.方便 灵活的中间件4.数据绑 ...

  6. gin框架教程一: go框架gin的基本使用

    我们在用http的时候一般都会用一些web框架来进行开发,gin就是这样的一个框架,它有哪些特点呢 一:gin特点 1.性能优秀2.基于官方的net/http的有限封装3.方便 灵活的中间件4.数据绑 ...

  7. 发现了一个关于 gin 1.3.0 框架的 bug

    gin 1.3.0 框架 http 响应数据错乱问题排查 问题概述 客户端同时发起多个http请求,gin接受到请求后,其中一个接口响应内容为空,另外一个接口响应内容包含接口1,接口2的响应内容,导致 ...

  8. 01 . Go之从零实现Web框架(框架雏形, 上下文Context,路由)

    设计一个框架 大部分时候,我们需要实现一个 Web 应用,第一反应是应该使用哪个框架.不同的框架设计理念和提供的功能有很大的差别.比如 Python 语言的 django和flask,前者大而全,后者 ...

  9. JAVA 中一个非常轻量级只有 200k 左右的 RESTful 路由框架

    ICEREST 是一个非常轻量级只有 200k 左右的 RESTful 路由框架,通过 ICEREST 你可以处理 url 的解析,数据的封装, Json 的输出,和传统的方法融合,请求的参数便是方法 ...

随机推荐

  1. codeforces C. Sonya and Problem Wihtout a Legend(dp or 思维)

    题目链接:http://codeforces.com/contest/713/problem/C 题解:这题也算是挺经典的题目了,这里附上3种解法优化程度层层递进,还有这里a[i]-i<=a[i ...

  2. Java 添加Word文本框

    在Word中,文本框是指一种可移动.可调节大小的文字或图形容器.我们可以向文本框中添加文字.图片.表格等对象,下面,将通过Java编程来实现添加以上对象到Word文本框. 使用工具:Free Spir ...

  3. 详解JAVA字符串类型switch的底层原理

    基础 我们现在使用的Java的版本,基本上是都支持String类型的.当然除了String类型,还有int.char.byte.short.enum等等也都是支持的.然而在其底部实现中,还是基于 整型 ...

  4. Wireshark解密HTTPS流量的两种方法

    原理 我们先回顾一下SSL/TLS的整个握手过程: Clienthello:发送客户端的功能和首选项给服务器,在连接建立后,当希望重协商.或者响应服务器的重协商请求时会发送. version:客户端支 ...

  5. jvm默认垃圾收集器(JDK789)

    jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代) jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel ...

  6. 在64位Linux上安装32位gmp大数库

    前期准备: 如果没有安装32位gcc和g++环境的话,可能会导致安装失败,此时请参考上一篇博文 http://www.cnblogs.com/weir007/p/5977759.html,根据系统版本 ...

  7. charles Glist发布设置

    本文参考:charles Glist发布设置 在这里可以设置Github账户, 发布list的大小限制:等等: 在这里 Auh 就是设置Github账户, 设置登陆你的Github后,才能针对该用户进 ...

  8. EasySwoole+ElasticSearch打造 高性能 小视频服务系统

    EasySwoole+ElasticSearch打造高性能小视频服务 第1章 课程概述 第2章 EasySwoole框架快速上手 第3章 性能测试 第4章 玩转高性能消息队列服务 第5章 小视频服务平 ...

  9. Elastic Stack 笔记(八)Elasticsearch5.6 Java API

    博客地址:http://www.moonxy.com 一.前言 Elasticsearch 底层依赖于 Lucene 库,而 Lucene 库完全是 Java 编写的,前面的文章都是发送的 RESTf ...

  10. Flink 从 0 到 1 学习 —— 如何自定义 Data Sink ?

    前言 前篇文章 <从0到1学习Flink>-- Data Sink 介绍 介绍了 Flink Data Sink,也介绍了 Flink 自带的 Sink,那么如何自定义自己的 Sink 呢 ...