4
\$\begingroup\$

I wrote a small (~350 lines) golang application mainly for fun to explore the language. I was wondering what the standard layout of a little testing tool like this would be from a seasoned golang dev. I also appreciate any feedback on the code itself.

My background is in C# where everything is typically spread out into different files because it is so object oriented, so my instinct after this ran correctly was to try splitting things out. But, I wanted to see what the norm is in the Go world.

The app is designed to set up a TCP listener to receive in HL7 messages (a string message with a certain schema used in the healthcare industry). Once a message is received, the application is to check the message contents according to a set list of rules listed in a JSON configuration file. If the message passes all the rules, a success is logged.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net"
    "os"
    "regexp"
    "time"
  "strconv"
  "strings"
)

type Config struct {
  Port int 
  SendAcks bool
  VerboseLogging bool
  Fields []FieldValidator 
}

type FieldValidator struct {
  LineName string
  FieldNumber int
  NonNullable bool
  Type string
  Pattern string
}

func LoadConfigs() (*Config, error){
  data, err := os.ReadFile("config.json")
  if err != nil {
    return nil, err
  }

  var config Config
  err = json.Unmarshal(data, &config)
  if err != nil {
    return nil, err
  }

  return &config, nil
}

type HL7 struct {
  Version string
  ControlId string
  Segments []Segment
  ValidMSH bool
}

type Segment struct {
  Header string
  NumFields int
  Fields []string
}

func newHl7Message (rawMessage []byte) *HL7{
  hl7 := &HL7{}

  segments := bytes.Split(rawMessage,[]byte("\r"))
  for _, seg := range segments {
    fields := strings.Split(string(seg), "|")
    if len(fields) > 0 {
      hl7.Segments = append(hl7.Segments, Segment{
        Header: fields[0],
        NumFields: len(fields),
        Fields: fields,
      })
    }
  }
  return hl7
}

func (m *HL7) validateMSH () {
  if(len(m.Segments) == 0 || m.Segments[0].Header != "MSH"){
    m.ValidMSH = false
    return
  }

  var firstLine = m.Segments[0]

  if (firstLine.Fields[9] == "" || firstLine.Fields[11] == "") {
    m.ValidMSH = false;
    return
  }

  m.ControlId = firstLine.Fields[9]
  m.Version = firstLine.Fields[11]
  m.ValidMSH = true;
}

type Message struct {
  Sender net.Conn
  Message  *HL7
}

type Server struct {
  listenPort string
  sendAcks bool
  listener net.Listener
  quitChan chan struct{}
  msgChan chan Message
}

func NewServer(port string, ack bool) *Server {
  return &Server{
    listenPort: port,
    sendAcks: ack,
    quitChan: make(chan struct{}),
    msgChan: make(chan Message, 10),
  }
}

func (s *Server) Start () error {
  listener, err := net.Listen("tcp", s.listenPort)
  if err != nil {
    return err
  }
  defer listener.Close()
  s.listener = listener

  fmt.Printf("HL7 Validator started and listening on port %s\n", s.listenPort)

  go s.acceptLoop()

  <-s.quitChan
  close(s.msgChan)

  return nil
}

func (s *Server) acceptLoop() {
  for {
    conn, err := s.listener.Accept()
    if err != nil {
      fmt.Printf("Error accepting the connection %s \n", err)
      continue
    }
    fmt.Printf("New Connection made. %s\n", conn.RemoteAddr())
    go s.readLoop(conn)
  }
}

func (s *Server) readLoop(conn net.Conn) {
  defer conn.Close()
  buf := make([]byte, 2048)
  var rawMsg []byte
  startOfMessage := byte(0x0B)
  endOfMessage   := []byte{0x1c,0x0D}

  for {
    n, err := conn.Read(buf)
    if err != nil {
      if err == io.EOF {
        fmt.Println("Connection closed by sender")
      } else {
      fmt.Printf("Error reading from connection to buffer %s", err)
      }
      break 
    }
    
    rawMsg = append(rawMsg, buf[:n]...)
    startIdx := bytes.Index(rawMsg, []byte{startOfMessage})
    endIdx   := bytes.Index(rawMsg, endOfMessage)

    if startIdx != -1  && endIdx != -1 {
      //Got a full HL7 Message
      hl7 := newHl7Message(rawMsg[startIdx+len([]byte{startOfMessage}):endIdx])
      hl7.validateMSH()

      if (hl7.ValidMSH){
        s.msgChan <- Message {
          Sender: conn,
          Message: hl7,
        }
        //Ack feature here? 
        if(s.sendAcks){
          fmt.Printf("Ack sent to %s\n", conn.RemoteAddr())
          ackMsg := fmt.Sprintf("\x0BMSH|^~\\&|||goValidateHL7|goValidateHL7|%s||ACK||D|%s\x0DMSA|AA|%s\x1C\x0D", time.Now().Format("20060102150405"), hl7.Version, hl7.ControlId)
          conn.Write([]byte(ackMsg))
        }
      } else {
        fmt.Printf("Received invalid HL7 Message from %s \n", conn.RemoteAddr())
      }
      rawMsg = rawMsg[endIdx+len(endOfMessage):]
    }
  }
}

type FieldResult struct {
  LineName string
  FieldNumber string
  Result string
}

type FailOutCome struct {
  Expected []FieldResult 
  Found []FieldResult 
  OutcomeSummary []string
  RegexFail bool
  NullFail bool
  TypeFail bool
}

func (f *FailOutCome) DidAllConfigsPass() bool {
  if (f.RegexFail || f.NullFail || f.TypeFail){
    return false
  }
  return true
}

func CheckAgainstConfigs (config *Config, hl7 *HL7) FailOutCome {
  outcome := FailOutCome{}
  for _, check := range config.Fields {
    for _, line := range hl7.Segments {
      if(check.LineName[:3] == line.Header){
        //Check if were doing more detailed line
        if(len(check.LineName) > 3){
          if(check.LineName[4:] != line.Fields[1]){
            break
          } 
        }
        outcomeSummary := "Pass";

        var spot string

        //NOTE Special handling for MSH because the split grabs something different than 7edit ui 
        if(check.LineName == "MSH"){
          spot = line.Fields[check.FieldNumber-1]
        } else {
          spot = line.Fields[check.FieldNumber]
        }
        //nullable
        if (check.NonNullable && spot == ""){
          outcome.NullFail = true
          outcomeSummary = "Found an empty field in that position."
        }
        //type
        if(check.Type == "number") {
          _, err := strconv.Atoi(spot)
          if err != nil {
            outcome.TypeFail = true
            outcomeSummary = "Unable to convert that field to a Number."
          }
        }
        //Pattern
        if(check.Pattern != ""){
          if(!strings.Contains(spot, check.Pattern)){
            match, _ := regexp.MatchString(check.Pattern,spot)
            if(!match){
              outcome.RegexFail = true
              outcomeSummary = "Unable to find a match using the reggex pattern passed in."
            }
          }
        }

        expected := FieldResult{
          LineName: check.LineName,
          FieldNumber: strconv.Itoa(check.FieldNumber),
          Result: check.Pattern,
        }

        found := FieldResult{
          LineName: line.Header,
          FieldNumber: strconv.Itoa(check.FieldNumber),
          Result: spot,
        }
        outcome.Expected = append(outcome.Expected, expected)
        outcome.Found = append(outcome.Found, found)
        outcome.OutcomeSummary = append(outcome.OutcomeSummary, outcomeSummary)
      }
    }

  }
  return outcome
}

func PrintSuccess(message string){
      // ANSI escape code for green color
    green := "\033[32m"

    // ANSI escape code to reset color
    reset := "\033[0m"

    fmt.Printf("%s%s%s\n", green, message, reset)
}

func PrintFailure(message string){
  // ANSI escape code for red color
    red := "\033[31m"

    // ANSI escape code to reset color
    reset := "\033[0m"

    fmt.Printf("%s%s%s\n", red, message, reset)
}

func (o *FailOutCome) Print (verbose bool, senderInfo string){
  success := o.DidAllConfigsPass()
  if(success) {
    PrintSuccess(fmt.Sprintf("%c Message from %s validated successfully.", '\u2714', senderInfo))
  } else {
    PrintFailure(fmt.Sprintf("%c Message from %s failed validation.", '\u2718', senderInfo))
  }
  
  if(verbose){
    fmt.Printf("==========VERBOSE LOGGING for message from %s========== \n",senderInfo)
    for i,msg := range o.OutcomeSummary {
      fmt.Println(">>>>>")
      fmt.Printf("Summary: %s \n",msg)
      fmt.Println("----------")
      fmt.Println("Expected")
      fmt.Printf("Looking at %s %s \n", o.Expected[i].LineName, o.Expected[i].FieldNumber)
      fmt.Printf("Trying to find or match this pattern: %s \n", o.Expected[i].Result)
      fmt.Println("----------")
      fmt.Println("FOUND")
      fmt.Printf("Looking at %s %s \n", o.Found[i].LineName, o.Found[i].FieldNumber)
      fmt.Printf("Found this in the field listed above %s \n", o.Found[i].Result)
      fmt.Println("<<<<<")
    }
    fmt.Printf("========== End VERBOSE LOGGING for message from %s========== \n",senderInfo)
  }
}


func main() {
  //Load configurations
  config, err := LoadConfigs()
  if err != nil {
    fmt.Printf("Error loading configs: %s\n", err)
    return
  }

  fmt.Println("Configs loaded!")
  
  //Open Listener
  server := NewServer(":" + strconv.Itoa(config.Port),config.SendAcks)


  //Validate messages based on configurations
  go func(){
    fmt.Println("Reading from the channel")
    for msg := range server.msgChan{
      result := CheckAgainstConfigs(config, msg.Message)
      result.Print(config.VerboseLogging, msg.Sender.RemoteAddr().String())
    }
  }()

  log.Fatal(server.Start())

  fmt.Printf("HL7 Validator started and listening on port %s\n", strconv.Itoa(config.Port))
} 

Example JSON configuration file:

{
  "Port" : 1234,
  "SendAcks" : true,
  "VerboseLogging": false,
  "Fields" : [
    {
      "LineName":"MSH",
      "FieldNumber": 4,
      "NonNullable": false,
      "Type": "string",
      "Pattern": "XYZ_ADMITTING"
    }
  ]
}
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

Formatting, please always use go fmt. I always enable auto-format on every save.

API: most of your code uses TitleCased names, but these are not importable as long as they reside in package named main. That's ok, no need to promise an API for now.

func LoadConfigs() (*Config, error)

It's "Config" instead of "Configs".

Note that formally, this func is a contructor of type Config. Usually a constructors are placed directly below the type constructed (and methods follow below constructors). So this func is more readable when placed above type FieldValidator.

func newHl7Message(rawMessage []byte) *HL7

Ditto. Also, normal convention is to retain acronyms: newHL7Message

hl7 := &HL7{}

But the method signature below is func (m *HL7) validateMSH(). In constructor, I would use the same naming m := &HL7{} to easily move code between methods and constructors in future.

fields := strings.Split(string(seg), "|")

Above you've used bytes.Split so I wonder whether you really know here that every line is a well-formed UTF-8 (which string.Split requires).

if len(fields) > 0

Note https://pkg.go.dev/strings#Split: "func Split(s, sep string) []string If s does not contain sep and sep is not empty, Split returns a slice of length 1 whose only element is s."

func (m *HL7) validateMSH()

This method should be probably public if the fields are public. If you turn this into an API, I'd really need to call m.Validate() after I've modified m.Segments.

Alternative idea (optional): you always use this method after calling newHl7Message, so there's a possibility here to tweak the design. This is a perfect place for a general pattern "make invalid states unrepresentable" (which some people narrow down to "parse, don't validate"). There is no rule that a newX constructor must be error-free. It can return (X, error), therefore ensuring that every instance of X is always valid. Of course this only works if you choose to not to have public fields like m.Segments.

func (s *Server) Start() error {
    listener, err := net.Listen("tcp", s.listenPort)
    if err != nil {
        return err
    }
    defer listener.Close()
    s.listener = listener

    fmt.Printf("HL7 Validator started and listening on port %s\n", s.listenPort)

    go s.acceptLoop()

    <-s.quitChan
    close(s.msgChan)

    return nil
}

Should be named Listen() instead of Start(), because per stdlib the Start implies there is Stop somewhere.

Nitpick: The <-s.quitChan could become ctx.Done, in which case the children of ctx could also be used to time out a read loop.

\$\endgroup\$
0

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.