使用interface化解一場因作業系統不同導致的編譯問題

failymao發表於2024-05-23

場景描述


起因

因專案需求,需要編寫一個agent, 需支援Linux和Windows作業系統。 Agent裡面有一個功能需要獲取到伺服器上所有已經被佔用的埠。

實現方式:針對不同的作業系統,實現方式有所不同

  • linux: 使用伺服器自帶的 netstat 指令,然後使用 os/exec 庫來呼叫 shell指令碼實現
  • windows: windows系統不同在於,使用 exec.Command指令後,需要呼叫 syscall.SysProcAttrsyscall.LoadDLL, 而這兩個方法是windows系統下的專用庫。

問題: 這裡會出出現一個問題,雖然程式在編譯的時候可以透過GOOS來區分編譯到指定的作業系統的二進位制包, 但是在編譯過程中,編譯器會進行程式碼檢查,也會載入windows的程式碼邏輯。


編譯爭端

初始程式碼如下:

  1. tools.go
    // get address
    func getAddress(addr string) string {
        var address string
        if strings.Contains(addr, "tcp") {
            address = strings.TrimRight(addr, "tcp")
        } else {
            address = strings.TrimRight(addr, "udp")
        }
        return address
    }
    
    // CollectServerUsedPorts, collect all of the ports that have been used
    func CollectServerUsedPorts(platform string) string {
        var (
            platformLower = strings.ToLower(platform)
            cmd           *exec.Cmd
            err           error
            cmdOutPut     []byte
        )
    
        if platformLower == "linux" {
            // 執行 shell 指令, 獲取tcp協議佔用的埠
            getUsedPortsCmd := `netstat -tln | awk '{print $4}' | awk -F: '{print $NF}' | egrep -o '[0-9]+' | sort -n | uniq | paste -s -d ","`
            cmd = exec.Command("bash", "-c", getUsedPortsCmd)
    
        } else if platformLower == "windows" {
            // 執行 powershell指令獲取已經佔用的埠號
            getUsedPortsCmd := SelectScriptByWindowsVersion()
    
            cmd = exec.Command("powershell", "-Command", getUsedPortsCmd)
            cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
    
        } else {
            cmd = nil
        }
    
        if cmd != nil {
            if cmdOutPut, err = cmd.Output(); err != nil {
                log.Errorf("err to execute command %s,  %s", cmd.String(), err.Error())
                return ""
            }
            return strings.Trim(string(cmdOutPut), "\n")
        }
        return ""
    }
    
    func SelectScriptByWindowsVersion() string {
        var getUsedPortsCmd string
        version, err := getWindowsVersion()
        if err != nil {
            log.Errorf("無法獲取Windows版本資訊: %s", err.Error())
            return ""
        }
    
        // if system version is lower than windows 8
        if version < 6.2 {
            log.Warnf("Windows 版本低於 Windows 8")
            getUsedPortsCmd = `(netstat -an | Select-String 'LISTENING' | ForEach-Object { $_ -replace '\s+', ' ' } | ForEach-Object { ($_ -split ' ')[2] } | Where-Object {$_ -match '\d'} | ForEach-Object {[int]($_ -split ':')[-1]} | Sort-Object | Get-Unique ) -join ",".Replace("r","")`
        } else {
            getUsedPortsCmd = `((Get-NetTCPConnection | Where-Object {$_.State -eq 'Listen'} | Select-Object -ExpandProperty LocalPort) | Sort-Object {[int]$_} | Get-Unique) -join ",".Replace("r","")`
        }
    
        return getUsedPortsCmd
    }
    
    func getWindowsVersion() (float64, error) {
    
        mod, err := syscall.LoadDLL("kernel32.dll")
        if err != nil {
            return 0, err
        }
        defer func() {
            _ = mod.Release()
        }()
    
        proc, err := mod.FindProc("GetVersion")
        if err != nil {
            return 0, err
        }
    
        version, _, _ := proc.Call()
        majorVersion := byte(version)
        minorVersion := byte(version >> 8)
    
        return float64(majorVersion) + float64(minorVersion)/10, nil
    }
    
  2. 上面程式碼編譯成windows沒問題,但是編譯linux二進位制檔案時,會提示:
    # 編譯linux二進位制檔案
    go build -ldflags "-linkmode external -extldflags '-static'" -tags musl -o  main main.go
    
    # 錯誤輸出如下
    windows.go:31:22: undefined: syscall.LoadDLL
    windows.go:56:41: unknown field 'HideWindow' in struct literal of type syscall.SysProcAttr
    
    # 錯誤原因
    - 內建庫syscall,在linux編譯時,其syscall.SysProcAttr 結構體並沒有`HideWindow`欄位;
    - linux 下也沒有 syscall.LoadDLL方法
    - 編譯和程式碼執行邏輯不一樣,雖然程式碼有檢查系統伺服器型別的邏輯,但是編譯時需要載入程式碼中的每一行程式碼邏輯,
    - 將其編譯成彙編,然後再交給計算機執行,所以會出現編譯錯誤
    

矛盾化解

Go語言在編譯時除了有對整個專案編譯的 引數控制 , 如 引數GOOS=windows表示編譯成widnwos系統下的二進位制檔案。 但是這個引數只能控制專案級別的, 對於上面這種情況,需要控制檔案級別的編譯, 當然 Go也是支援的,在提取出 windows 邏輯的程式碼為獨立檔案,在檔案開頭使用 // + build windows 語法。修改如下:

  1. used_ports/windows.go如下:
      //  +build windows
    func SelectScriptByWindowsVersion() string {
        var getUsedPortsCmd string
        version, err := getWindowsVersion()
        if err != nil {
            log.Errorf("無法獲取Windows版本資訊: %s", err.Error())
            return ""
        }
    
        // if system version is lower than windows 8
        if version < 6.2 {
            log.Warnf("Windows 版本低於 Windows 8")
            getUsedPortsCmd = `(netstat -an | Select-String 'LISTENING' | ForEach-Object { $_ -replace '\s+', ' ' } | ForEach-Object { ($_ -split ' ')[2] } | Where-Object {$_ -match '\d'} | ForEach-Object {[int]($_ -split ':')[-1]} | Sort-Object | Get-Unique ) -join ",".Replace("r","")`
        } else {
            getUsedPortsCmd = `((Get-NetTCPConnection | Where-Object {$_.State -eq 'Listen'} | Select-Object -ExpandProperty LocalPort) | Sort-Object {[int]$_} | Get-Unique) -join ",".Replace("r","")`
        }
    
        return getUsedPortsCmd
    }
    
    func getWindowsVersion() (float64, error) {
    
        mod, err := syscall.LoadDLL("kernel32.dll")
        if err != nil {
            return 0, err
        }
        defer func() {
            _ = mod.Release()
        }()
    
        proc, err := mod.FindProc("GetVersion")
        if err != nil {
            return 0, err
        }
    
        version, _, _ := proc.Call()
        majorVersion := byte(version)
        minorVersion := byte(version >> 8)
    
        return float64(majorVersion) + float64(minorVersion)/10, nil
    }
    

  1. 將原來邏輯改成如下:

    • windows.go
      // CollectServerUsedPorts, collect all of the ports that have been used
      func CollectServerUsedPorts(platform string) string {
          var (
              platformLower = strings.ToLower(platform)
              cmd           *exec.Cmd
              err           error
              cmdOutPut     []byte
          )
      
          if platformLower == "linux" {
              // 執行 shell 指令, 獲取tcp協議佔用的埠
              getUsedPortsCmd := `netstat -tln | awk '{print $4}' | awk -F: '{print $NF}' | egrep -o '[0-9]+' | sort -n | uniq | paste -s -d ","`
              cmd = exec.Command("bash", "-c", getUsedPortsCmd)
          
          } else if platformLower == "windows" {
              // todo: 需要最佳化,透過介面對映避免編譯的問題
              // Linux 編譯時需要隱藏下面程式碼,
              getUsedPortsCmd := used_ports.CollectWindowsUsedPorts()
              cmd = exec.Command("powershell", "-Command", getUsedPortsCmd)
              cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
      
          } else {
              cmd = nil
          }
      
          if cmd != nil {
              if cmdOutPut, err = cmd.Output(); err != nil {
                  log.Errorf("err to execute command %s,  %s", cmd.String(), err.Error())
                  return ""
              }
              return strings.Trim(string(cmdOutPut), "\n")
          }
          return ""
      }
      

  2. 光修改了上面的邏輯也不行,因為編譯的時候程式碼依然會執行,此時會報如下錯誤

    tools.go:121:15: undefined: used_ports.CollectWindowsUsedPorts
    
    # 這是因為雖然linux編譯時,不會編譯 windows.go的檔案,同時會導致 模組下的
    #CollectWindowsUsedPorts 方法不存在
    

  3. 最終修復方式如下

    • 編譯時考慮使用 定義介面的方式, 針對不同作業系統使用不同的 結構體,然後透過結構實現介面的方式來使其兩種作業系統方法來指向同一個介面
    • 使用 介面字典的方式,實現策略模式,不再使用顯示的 if 判斷語法來做顯示判斷,這樣可以避免編譯時顯示載入因作業系統帶來的編譯衝突
    • 使用init() 方式初始化 介面實現
    • 最終程式碼如下
      /* 實現介面目錄如下
      ├── used_ports
      │   ├── linux.go
      │   ├── used_ports.go
      │   └── windows.go
      */
      
    • used_ports.go
      package used_ports
      
      import "os/exec"
      
      type UsedPortCollector interface {
          CollectHaveUsedPorts() *exec.Cmd
      }
      
      var UsedPortCollectorMap = make(map[string]UsedPortCollector, 2)
      
      func Register(platformOS string, collector UsedPortCollector) {
          if _, ok := Find(platformOS); ok {
              return
          }
      
          UsedPortCollectorMap[platformOS] = collector
      }
      
      func Find(platformOS string) (UsedPortCollector, bool) {
      
          c, ok := UsedPortCollectorMap[platformOS]
          return c, ok
      }
      
    • linux.go
      package used_ports
        
      import (
          "os/exec"
      )
        
      func init() {
          Register("linux", newLinuxUsedPorts())
      }
        
      type linuxPortCollectorImpl struct{}
        
      func (w linuxPortCollectorImpl) CollectHaveUsedPorts() *exec.Cmd {
          // 執行 shell 指令, 獲取tcp協議佔用的埠
          getUsedPortsCmd := `netstat -tln | awk '{print $4}' | awk -F: '{print $NF}' | egrep -o '[0-9]+' | sort -n | uniq | paste -s -d ","`
          cmd := exec.Command("bash", "-c", getUsedPortsCmd)
          return cmd
      }
        
         // newLinuxUsedPorts 返回Windows系統下的埠收集器例項
      func newLinuxUsedPorts() UsedPortCollector {
          return linuxPortCollectorImpl{}
      }
      
      • windows.go
      //  +build windows
      
      package used_ports
      
      import (
          "os/exec"
          "syscall"
      )
      
      func init() {
          Register("windows", newWindowsCollector())
      }
      
      // 結構體
      type windowsPortCollectorImpl struct{}
      
      // 實現介面方法
      func (w windowsPortCollectorImpl) CollectHaveUsedPorts() *exec.Cmd {
          // 執行 powershell指令獲取已經佔用的埠號
          getUsedPortsCmd := selectScriptByWindowsVersion()
      
          cmd := exec.Command("powershell", "-Command", getUsedPortsCmd)
          cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
      
          return cmd
      }
      
      // newWindowsCollector 返回Windows系統下的埠收集器例項,算是工廠方法
      func newWindowsCollector() UsedPortCollector {
          return windowsPortCollectorImpl{}
      }
      
      func selectScriptByWindowsVersion() string {
          var getUsedPortsCmd string
          version, err := getWindowsVersion()
          if err != nil {
              return ""
          }
      
          // if system version is lower than windows 8
          if version < 6.2 {
              getUsedPortsCmd = `(netstat -an | Select-String 'LISTENING' | ForEach-Object { $_ -replace '\s+', ' ' } | ForEach-Object { ($_ -split ' ')[2] } | Where-Object {$_ -match '\d'} | ForEach-Object {[int]($_ -split ':')[-1]} | Sort-Object | Get-Unique ) -join ",".Replace("r","")`
          } else {
              getUsedPortsCmd = `((Get-NetTCPConnection | Where-Object {$_.State -eq 'Listen'} | Select-Object -ExpandProperty LocalPort) | Sort-Object {[int]$_} | Get-Unique) -join ",".Replace("r","")`
          }
      
          return getUsedPortsCmd
      }
      
      func getWindowsVersion() (float64, error) {
      
          mod, err := syscall.LoadDLL("kernel32.dll")
          if err != nil {
              return 0, err
          }
          defer func() {
              _ = mod.Release()
          }()
      
          proc, err := mod.FindProc("GetVersion")
          if err != nil {
              return 0, err
          }
      
          version, _, _ := proc.Call()
          majorVersion := byte(version)
          minorVersion := byte(version >> 8)
      
          return float64(majorVersion) + float64(minorVersion)/10, nil
      }
      
    • tools.go
      ...
      
      // CollectServerUsedPorts, collect all of the ports that have been used
      func CollectServerUsedPorts(platform string) string {
          var (
              platformLower = strings.ToLower(platform)
              cmd           *exec.Cmd
              err           error
              cmdOutPut     []byte
          )
         
         // 策略方法,獲取作業系統對應的例項(介面)  
          if portCollector, ok := used_ports.Find(platformLower); ok {
              cmd = portCollector.CollectHaveUsedPorts()
          }
      
          if cmd != nil {
              if cmdOutPut, err = cmd.Output(); err != nil {
                  log.Errorf("err to execute command %s,  %s", cmd.String(), err.Error())
                  return ""
              }
              return strings.Trim(string(cmdOutPut), "\n")
          }
      
          return ""
      }
      

總結

  1. 程式級別的控制可以在 編譯時使用 GOOS=windows 來區分編譯成對應作業系統的二進位制檔案
  2. 檔案級別的控制可以在檔案頭上使用 // + build windows進行控制
  3. 程式碼級別的控制,可以是使用 結構體對映介面的方式進行區分
  4. init()初始化方法的使用
  5. 不同結構體只要實現了同一個介面的所有方法,那麼可以使用 字典介面來實現程式碼層面的控制

相關文章