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"
 }
 ]
}
toolic
14.4k5 gold badges29 silver badges201 bronze badges
asked Nov 4, 2024 at 21:08
\$\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.

answered Feb 10 at 16:19
\$\endgroup\$
0

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.