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"
}
]
}
1 Answer 1
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.