我们选择基础库时,可能会选择一些第三方的库,第三方的库一般会有不同的license,下面整理了常见的license。绿色是授予的权限,蓝色是要遵守的条件,红色是限制。下面图片来源于https://choosealicense.com/appendix/,目前github版权信息也是使用它的。

License

问题

最近在做iris框架切换fiber框架时,碰到一个坑,fiber看注释说是默认使用官方标准库的json解析库,iris默认也是使用官方标准库的json解析库,所以切换后没有关注这方面,后面业务研发人员反馈,框架切换后,解析json有问题,以前能解析出来,现在解析不出来了

// 这个结构体,注意看是[]uint8
type Param struct {
    InputIdList []uint8 `json:"inputIdList"`
}

// fiber里解析也很容易
app.Post("/aa", func(ctx *fiber.Ctx) error {
    var param Param
    err := ctx.BodyParser(&param)
    if err != nil {
        //实际代码跑到这里,提示json: cannot unmarshal  into Go value of type uint8
        return err
    }
    // 其他业务逻辑
    // xxxx
    return nil
}

后面跟踪进去发现fiber里使用的是go-json

最后解决方案也很简单,在配置里配置使用官方标准库


app := fiber.New(fiber.Config{
        // json 编码库
        JSONEncoder: json.Marshal,
        // json 解码库
        JSONDecoder: json.Unmarshal,
    })

然后解析就正常了,然后再看编码,发现结构体里含有[]uint8,输出会转成[]byte,在base64编码,如果用刚才param的结构体,输出就是


{
"inputIdList": "AQI="
}

我们期望的结果是


{
"inputIdList": [1,2]
}

后面上网搜索了一下,发现大概都是这样的解决方案


import "fmt"
import "encoding/json"
import "strings"

type Test struct {
    Name  string
    Array []uint8
}

func (t *Test) MarshalJSON() ([]byte, error) {
    var array string
    if t.Array == nil {
        array = "null"
    } else {
        array = strings.Join(strings.Fields(fmt.Sprintf("%d", t.Array)), ",")
    }
    jsonResult := fmt.Sprintf(`{"Name":%q,"Array":%s}`, t.Name, array)
    return []byte(jsonResult), nil
}


func main() {
    t := &Test{"Go", []uint8{'h', 'e', 'l', 'l', 'o'}}

    m, err := json.Marshal(t)
    if err != nil {
        fmt.Println(err)
    }
    fmt.Printf("%s", m) // {"Name":"Go","Array":[104,101,108,108,111]}
}

其实就是结构体序列化的时候可以自定义序列化的方法,不使用官方的,那按这么说,我是不是可以对某个结构按我定义的方法去编码和解码呢?


type Param struct {
    InputIdList []uint8 `json:"inputIdList"`
}


// json编码
func (p *Param) MarshalJSON() ([]byte, error) {
    var array string
    if p.InputIdList == nil {
        array = "null"
    } else {
        array = strings.Join(strings.Fields(fmt.Sprintf("%d", p.InputIdList)), ",")
    }
    jsonResult := fmt.Sprintf(`{"inputIdList":%s}`, array)
    return []byte(jsonResult), nil
}

// json解码
func (p *Param) UnmarshalJSON(data []byte) error {
    validID := regexp.MustCompile(`\{"inputIdList":\[(.*)\]\}`)
    decoderData := validID.FindAllStringSubmatch(string(data), -1)
    if len(decoderData) < 1 {
        return errors.New("json decoder error")
    }
    inputIdList := strings.Split(decoderData[0][1], ",")
    for _, v := range inputIdList {
        id, _ := strconv.Atoi(v)
        p.InputIdList = append(p.InputIdList, uint8(id))
    }
    return nil
}

再回到刚才配置那里,我将官方标准库注释,让他使用回go-json库,发现现在解码和编码都按预期输出了

总结

  1. 框架迁移时,尽量使用之前的编解码库,比如json库,虽然说go-json是和encoding/json是兼容的,但是可能部分场景下解析不同,如果你之前业务已经上线了,那切换时测试覆盖率能否达到100%?如果不能,请不要轻易切换
  2. json解析某个结构时,可以自定义编解码方法

问题是这样的,前端需要和后端建立websocket连接,并且需要发心跳包,而且允许同一个用户同时给多人登录,且可以打开多个tab页面,每一个tab页面都会和后端建立连接,并且每隔3秒发一次心跳包。服务端为了测试,将心跳判断限制去掉了。

<!DOCTYPE html>
<html lang="en">
<head>
<title>Chat Example</title>
<script type="text/javascript">
window.onload = function () {
    var conn;
    var msg = document.getElementById("msg");
    var log = document.getElementById("log");

    function appendLog(item) {
        var doScroll = log.scrollTop > log.scrollHeight - log.clientHeight - 1;
        log.appendChild(item);
        if (doScroll) {
            log.scrollTop = log.scrollHeight - log.clientHeight;
        }
    }

    document.getElementById("form").onsubmit = function () {
        if (!conn) {
            return false;
        }
        if (!msg.value) {
            return false;
        }
        conn.send(msg.value);
        msg.value = "";
        return false;
    };

    if (window["WebSocket"]) {
        conn = new WebSocket("ws://127.0.0.1:8081/ws");
        conn.onclose = function (evt) {
            var item = document.createElement("div");
            item.innerHTML = "<b>Connection closed.</b>";
            appendLog(item);
        };
        var heartbeat = function(){
            conn.send('{"heartbeat":1}')
            setTimeout(heartbeat, 3000);
        }
        conn.onmessage = function (evt) {
            var messages = evt.data.split('\n');
            for (var i = 0; i < messages.length; i++) {
                var item = document.createElement("div");
                item.innerText = messages[i];
                appendLog(item);
            }
        };
        conn.onopen = function () {
            
            setTimeout(heartbeat, 3000);
            conn.send('{"token":"userToken"}')
        }
        conn.onerror = function (error) {
        console.log('WebSocket Error ' + error);
        };


        
        
    } else {
        var item = document.createElement("div");
        item.innerHTML = "<b>Your browser does not support WebSockets.</b>";
        appendLog(item);
    }
};
</script>
<style type="text/css">
html {
    overflow: hidden;
}

body {
    overflow: hidden;
    padding: 0;
    margin: 0;
    width: 100%;
    height: 100%;
    background: gray;
}

#log {
    background: white;
    margin: 0;
    padding: 0.5em 0.5em 0.5em 0.5em;
    position: absolute;
    top: 0.5em;
    left: 0.5em;
    right: 0.5em;
    bottom: 3em;
    overflow: auto;
}

#form {
    padding: 0 0.5em 0 0.5em;
    margin: 0;
    position: absolute;
    bottom: 1em;
    left: 0px;
    width: 100%;
    overflow: hidden;
}

</style>
</head>
<body>
<div id="log"></div>
<form id="form">
    <input type="submit" value="Send" />
    <input type="text" id="msg" size="64" autofocus />
</form>
</body>
</html>

之前本来是用setInterval的,后面看到这篇文章为什么有时候用 setTimeout 替代 setInterval?后,改用setTimeout,依旧出现这样的情况

正常应该是每3秒执行一次

后面怀疑是不是setTimeout 有bug,所以查了下chrome定时器源码分析,

其中说到webkit/glue/webkit_constants.h这里的代码是定时器的配置


// Copyright (c) 2011 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#ifndef WEBKIT_GLUE_WEBKIT_CONSTANTS_H_
#define WEBKIT_GLUE_WEBKIT_CONSTANTS_H_

namespace webkit_glue {

// Chromium 将最小间隔超时设置为 4ms,覆盖
// 默认为 10 毫秒。 我们想走得更低,但是情况很差
// 那些确实会创建 CPU 旋转循环的编码网站。 使用
// 4ms 防止 CPU 过于忙碌并提供平衡
// 在 CPU 旋转和可能的最小间隔计时器之间。
const double kForegroundTabTimerInterval = 0.004;

// 控制后台选项卡的最小计时器间隔。
const double kBackgroundTabTimerInterval = 1.0;

} // namespace webkit_glue


#endif  // WEBKIT_GLUE_WEBKIT_CONSTANTS_H_

content/renderer/render_view_impl.cc

RenderViewImpl::RenderViewImpl这个函数


 webview()->settings()->setMinimumTimerInterval(
      is_hidden() ? webkit_glue::kBackgroundTabTimerInterval :
          webkit_glue::kForegroundTabTimerInterval);

先判断tab页面是否隐藏。如果隐藏就设置最小计时器间隔为1s,如果是活动页面,设置最小计时器间隔为4ms

故将


var heartbeat = function(){
    conn.send('{"heartbeat":1}')
    setTimeout(heartbeat, 500);//这里之前是3000,表示3s发一次心跳
}

然后切换到浏览器,打开页面后切换到其他tab页面,过了10秒再回来看发送记录,发现的确是1秒执行一次。

每秒执行一次

但是我们之前偶现是1分钟执行一次,后面google了一下, 后面发现chrome://flags/#intensive-wake-up-throttling 谷歌浏览器里有个参数,控制浏览器唤醒,看谷歌的解释是 启用后,从 DOM 计时器唤醒在已隐藏 5 分钟的页面中限制为每分钟 1 次。 有关其他详细信息,请参阅 https://www.chromestatus.com/feature/4718288976216064。 – Mac、Windows、Linux、Chrome 操作系统、Android

后面打开https://www.chromestatus.com/feature/4718288976216064,发现chrome为了延长了电池使用时间,加了限制:如果tab页面不是活动页,对已隐藏 5 分钟的页面限制为每分钟执行 1 次setTimeout或者setInterval

有时候用golang启动子进程后,想通过退出码获取程序是否正常退出。

先上结果


package main

import (
    "log"
    "os/exec"
    "syscall"
)

func main() {
    cmd := exec.Command("/bin/bash", "-c", "ls -")

    if err := cmd.Start(); err != nil {
        log.Printf("cmd.Start error: %v", err)
    }

    var exitCode int
    if err := cmd.Wait(); err != nil {
        if exitError, ok := err.(*exec.ExitError); ok {
            exitCode = exitError.ExitCode()
        }
    }

    log.Printf("exit code %d", exitCode)
}

结果虽然很重要,但是过程也一样不可或缺

一般流程都是


cmd := exec.Command

cmd.Start

cmd.Wait

所以进去看下源码

wait源码


func (c *Cmd) Wait() error {
    if c.Process == nil {
        return errors.New("exec: not started")
    }
    if c.finished {
        return errors.New("exec: Wait was already called")
    }
    c.finished = true
    state, err := c.Process.Wait()
    if c.waitDone != nil {
        close(c.waitDone)
    }
    c.ProcessState = state
    var copyError error
    for range c.goroutine {
        if err := <-c.errch; err != nil && copyError == nil {
            copyError = err
        }
    }
    c.closeDescriptors(c.closeAfterWait)
    if err != nil {
        return err
    } else if !state.Success() {
             //不是正常退出返回这个错误
        return &ExitError{ProcessState: state}
    }
    return copyError
}

发现返回这个错误&ExitError{ProcessState: state}

再查ProcessState

发现有这么个方法



// ExitCode returns the exit code of the exited process, or -1
// if the process hasn't exited or was terminated by a signal.
func (p *ProcessState) ExitCode() int {

其实就是一步一步看源码的过程

内核

先看一下内核


Linux workpc 3.10.0-1127.el7.x86_64 #1 SMP Tue Mar 31 23:36:51 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux

问题

正常监听随机端口


ln, err := net.Listen("tcp", ":0")

if err != nil {
    log.Fatalln(err)
}
log.Printf("listen port %s", ln.Addr())
for {
    _, err := ln.Accept()
    if err != nil {
        // handle error
    }
    // go handleConnection(conn)
}

但是有时候发现监听失败,但是在测试机上没失败过,生产服务器上很容易失败


listen tcp :0: bind: address already in use

猜测

端口受内核参数net.ipv4.ip_local_port_range影响,但是这个参数在之前的使用者都是拿来作为客户端的时候控制对外连接的端口范围,防止需要监听的端口被占用和扩大端口范围,增加对外连接数

今天去官方看了下kernel文档


ip_local_port_range - 2 INTEGERS
    Defines the local port range that is used by TCP and UDP to
    choose the local port. The first number is the first, the
    second the last local port number.
    If possible, it is better these numbers have different parity
    (one even and one odd value).
    Must be greater than or equal to ip_unprivileged_port_start.
    The default values are 32768 and 60999 respectively.

大概意思是说它是定义TCP和UDP使用的本地端口范围,没说是作为客户端还是服务端。

这里我猜测随机获取可用端口的时候,也是按这个范围来返回的

验证

下面做个实验验证一下


# echo "30000 30010" | sudo tee /proc/sys/net/ipv4/ip_local_port_range
30000 30010

# cat /proc/sys/net/ipv4/ip_local_port_range
30000    30010

现在控制30000到30010


package main

import (
    "fmt"
    "log"
    "net"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 30000; i <= 30010; i++ {
                //先注释这里
        //if i == 30001 {
        //    continue
        //}
        wg.Add(1)
        go func(i int) {
            ln, err := net.Listen("tcp", ":"+fmt.Sprintf("%d", i))
            wg.Done()

            if err != nil {
                return
            }
            for {
                _, err := ln.Accept()
                if err != nil {
                    // handle error
                }
                // go handleConnection(conn)
            }
        }(i)
    }
    wg.Wait()
    log.Println("success listen")

    ln, err := net.Listen("tcp", ":0")

    if err != nil {
        log.Fatalln(err)
    }
    log.Printf("listen port %s", ln.Addr())
    for {
        _, err := ln.Accept()
        if err != nil {
            // handle error
        }
        // go handleConnection(conn)
    }
    return

}

现在设置范围是30000-30010的端口,然后随机监听端口

提示listen tcp :0: bind: address already in use

然后将注释去掉



package main

import (
    "fmt"
    "log"
    "net"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 30000; i <= 30010; i++ {
                //去掉注释
        if i == 30001 {
            continue
        }
        wg.Add(1)
        go func(i int) {
            ln, err := net.Listen("tcp", ":"+fmt.Sprintf("%d", i))
            wg.Done()

            if err != nil {
                return
            }
            for {
                _, err := ln.Accept()
                if err != nil {
                    // handle error
                }
                // go handleConnection(conn)
            }
        }(i)
    }
    wg.Wait()
    log.Println("success listen")

    ln, err := net.Listen("tcp", ":0")

    if err != nil {
        log.Fatalln(err)
    }
    log.Printf("listen port %s", ln.Addr())
    for {
        _, err := ln.Accept()
        if err != nil {
            // handle error
        }
        // go handleConnection(conn)
    }
    return

}

去掉注释,执行,一致是监听30001端口


2021/03/11 08:53:39 success listen
2021/03/11 08:53:39 listen port [::]:30001

再将端口范围改大


# echo "30000 30011" | sudo tee /proc/sys/net/ipv4/ip_local_port_range
30000 30011
# cat /proc/sys/net/ipv4/ip_local_port_range
30000    30011

30000-30010端口全部被占用,这个时候如果随机监听端口,如果最后监听是30011端口,那表示系统随机监听端口就是受ip_local_port_range这个参数影响的


package main

import (
    "fmt"
    "log"
    "net"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    for i := 30000; i <= 30010; i++ {
        // if i == 30001 {
        //     continue
        // }
        wg.Add(1)
        go func(i int) {
            ln, err := net.Listen("tcp", ":"+fmt.Sprintf("%d", i))
            wg.Done()

            if err != nil {
                return
            }
            for {
                _, err := ln.Accept()
                if err != nil {
                    // handle error
                }
                // go handleConnection(conn)
            }
        }(i)
    }
    wg.Wait()
    log.Println("success listen")

    ln, err := net.Listen("tcp", ":0")

    if err != nil {
        log.Fatalln(err)
    }
    log.Printf("listen port %s", ln.Addr())
    for {
        _, err := ln.Accept()
        if err != nil {
            // handle error
        }
        // go handleConnection(conn)
    }
    return

}


2021/03/11 08:56:14 success listen
2021/03/11 08:56:14 listen port [::]:30011

最后结论:随机监听端口是受ip_local_port_range这个内核参数影响的。

回到刚才的问题,生产环境容易出现,测试不容易出现,因为生产环境上可能还有其他程序在对外连接,可能内核参数设置过小,导致没有可用的端口了,所以随机监听端口容易失败。

解决方案

解决方案也很容易

  1. 调大ip_local_port_range范围
  2. 不用随机端口的方式去监听,而是在程序里直接监听,如果这个端口号失败,直接加1,继续监听,直到成功为止