Aurelien
Aurelien

Reputation: 21

Unmarshall nested YAML in a struct with different nested objects but a without common method to satifsfy an interface

I looking for an elegant solution to unmarshall yaml with different kind of nested object without interface which requires to have a common method (polymorphism).

Example 1

Source

Consider the following yaml :

agents: 
  - 
    identifier: agent_1
    type: lawyer
    parameters:
      name: "My agent 1"
      speciality: "law"
      sub_speciality: "criminal"
  - 
    identifier: agent_2
    type: accountant
    parameters:
      name: "My agent 2"
      expertise: "accounting"
  - 
    identifier: agent_3
    type: carpenter
    parameters:
      name: "My agent 3"
      tool: "hammer"

Description : This yaml contain a list of agents, each agent has a single type, several agent can have the same type or a different type.

Unmarshaller

In order to unmarshall this yaml into struct we need to define the following go function :

package main

import (
    "fmt"
    "log"
    "os"

    "gopkg.in/yaml.v3"
)

const (
    LawAgentType       = "lawyer"
    AccountAgentType   = "accountant"
    CarpenterAgentType = "carpenter"
)

type (
    Agents struct {
        Agent []Agent `yaml:"agents"`
    }

    Agent struct {
        Identifier    string              `yaml:"identifier"`
        Type          string              `yaml:"type"`
        ParametersRaw yaml.Node           `yaml:"parameters"`
        Parameters    ParametersInterface `yaml:"-"`
    }

    ParametersInterface interface {
        GetName() string
    }

    LawParameters struct {
        Parameters    `yaml:",inline"`
        Speciality    string `yaml:"speciality"`
        SubSpeciality string `yaml:"sub_speciality"`
    }

    AccountParameters struct {
        Parameters `yaml:",inline"`
        Expertise  string `yaml:"expertise"`
    }

    CarpenterParameters struct {
        Parameters `yaml:",inline"`
        Tool       string `yaml:"tool"`
    }

    Parameters struct {
        Name string `yaml:"name"`
    }
)

func (p *Parameters) GetName() string {
    return p.Name
}

func main() {
    f, err := os.ReadFile("config.yaml")
    if err != nil {
        log.Panic("Error reading config file")
    }

    var agents *Agents
    err = yaml.Unmarshal(f, &agents)
    if err != nil {
        fmt.Println(err.Error())
    }

    for i, agent := range agents.Agent {
        switch agent.Type {
        case LawAgentType:
            var parameters LawParameters
            err = agent.ParametersRaw.Decode(&parameters)
            if err != nil {
                log.Panic("Error reading config file")
            }
            agents.Agent[i].Parameters = &parameters
        case AccountAgentType:
            var parameters AccountParameters
            err = agent.ParametersRaw.Decode(&parameters)
            if err != nil {
                log.Panic("Error reading config file")
            }
            agents.Agent[i].Parameters = &parameters
        case CarpenterAgentType:
            var parameters CarpenterParameters
            err = agent.ParametersRaw.Decode(&parameters)
            if err != nil {
                log.Panic("Error reading config file")
            }
            agents.Agent[i].Parameters = &parameters
        }
    }

    fmt.Println(agents)
}

This method returns the right data in right struct :

yaml-unmarshal-debug

Explanation

Agent struct has a field ParametersRaw which is a node.Yaml.

First, we unmarshall yaml to get the type, then we unmarshall ParametersRaw into the right struct according to its type.

All the agent types have the same interface ParametersInterface satisfied by GetName() carried by the Parameters struct and embedded in each agent type (LawAgentType, AccountAgentType, CarpenterAgentType)

Question

Because it's possible to store different object under parameters that don't always have a common denominator (ex in previous example: Name), I'd now like to proceed in the same way, but without a common denominator.

Please, consider the following Yaml :

agents: 
  - 
    identifier: agent_1
    type: lawyer
    parameters:
      speciality: "law"
      sub_speciality: "criminal"
  - 
    identifier: agent_2
    type: accountant
    parameters:
      expertise: "accounting"
  - 
    identifier: agent_3
    type: carpenter
    parameters:
      tool: "hammer"

We no longer have the common denominator (field name under Parameters, nor Parameters struct).

type (
    Agents struct {
        Agent []Agent `yaml:"agents"`
    }

    Agent struct {
        Identifier    string              `yaml:"identifier"`
        Type          string              `yaml:"type"`
        ParametersRaw yaml.Node           `yaml:"parameters"`
        Parameters    ????                `yaml:"-"`
    }

    LawParameters struct {
        Speciality    string `yaml:"speciality"`
        SubSpeciality string `yaml:"sub_speciality"`
    }

    AccountParameters struct {
        Expertise  string `yaml:"expertise"`
    }

    CarpenterParameters struct {
        Tool       string `yaml:"tool"`
    }
)

Is it possible to store different kind of parameters (LawAgentType, AccountAgentType, CarpenterAgentType) under Agent struct's Parameters field without defining a common field to satisfy an interface ? In other word, can we combine structures under a single struct/interface without identical fields like the classic inheritance in other OOP languages (even if go doesn't have an OOP model) ?

Possible solution

Define a struct which wrap all sub struct as follows:

agents: 
  - 
    identifier: agent_1
    type: lawyer
    parameters:
      speciality: "law"
      sub_speciality: "criminal"
  - 
    identifier: agent_2
    type: accountant
    parameters:
      expertise: "accounting"
  - 
    identifier: agent_3
    type: carpenter
    parameters:
      tool: "hammer"
package main

import (
    "fmt"
    "log"
    "os"

    "gopkg.in/yaml.v3"
)

const (
    LawAgentType       = "lawyer"
    AccountAgentType   = "accountant"
    CarpenterAgentType = "carpenter"
)

type (
    Agents struct {
        Agent []Agent `yaml:"agents"`
    }

    Agent struct {
        Identifier    string     `yaml:"identifier"`
        Type          string     `yaml:"type"`
        ParametersRaw yaml.Node  `yaml:"parameters"`
        Parameters    Parameters `yaml:"-"`
    }

    Parameters struct {
        *LawParameters
        *AccountParameters
        *CarpenterParameters
    }

    LawParameters struct {
        Speciality    string `yaml:"speciality"`
        SubSpeciality string `yaml:"sub_speciality"`
    }

    AccountParameters struct {
        Expertise string `yaml:"expertise"`
    }

    CarpenterParameters struct {
        Tool string `yaml:"tool"`
    }
)

func main() {
    f, err := os.ReadFile("config.yaml")
    if err != nil {
        log.Panic("Error reading config file")
    }

    var agents *Agents
    err = yaml.Unmarshal(f, &agents)
    if err != nil {
        fmt.Println(err.Error())
    }

    for i, agent := range agents.Agent {
        switch agent.Type {
        case LawAgentType:
            var parameters LawParameters
            err = agent.ParametersRaw.Decode(&parameters)
            if err != nil {
                log.Panic("Error reading config file")
            }
            agents.Agent[i].Parameters.LawParameters = &parameters
        case AccountAgentType:
            var parameters AccountParameters
            err = agent.ParametersRaw.Decode(&parameters)
            if err != nil {
                log.Panic("Error reading config file")
            }
            agents.Agent[i].Parameters.AccountParameters = &parameters
        case CarpenterAgentType:
            var parameters CarpenterParameters
            err = agent.ParametersRaw.Decode(&parameters)
            if err != nil {
                log.Panic("Error reading config file")
            }
            agents.Agent[i].Parameters.CarpenterParameters = &parameters
        }
    }

    fmt.Println(agents)
}

Result :

yaml-unmarshall-struct-2


I don't find this solution elegant since it implies having uninitialised struct under the Parameters struct (ex: if LawParameters is set, AccountParameters and CarpenterParameters are not). I'm looking for a more appropriate solution if any of you have one.

Upvotes: 2

Views: 71

Answers (1)

Vahag Zargaryan
Vahag Zargaryan

Reputation: 1

You can move Name field into Agent struct and set Parameters field's type empty interface (interface{}). To define each agent's parameters type you can implement yaml.Unmarshaler interface in Agent type like below.

package main

import (
    "fmt"
    "log"
    "os"

    "gopkg.in/yaml.v3"
)

const (
    LawAgentType       = "lawyer"
    AccountAgentType   = "accountant"
    CarpenterAgentType = "carpenter"
)

type (
    Agents struct {
        Agent []Agent `yaml:"agents"`
    }

    Agent struct {
        Identifier string
        Type       string
        Name       string
        Parameters interface{}
    }

    LawParameters struct {
        Speciality    string `yaml:"speciality"`
        SubSpeciality string `yaml:"sub_speciality"`
    }

    AccountParameters struct {
        Expertise string `yaml:"expertise"`
    }

    CarpenterParameters struct {
        Tool string `yaml:"tool"`
    }
)

func (a *Agent) UnmarshalYAML(value *yaml.Node) error {
    a.Identifier = value.Content[1].Value
    a.Type = value.Content[3].Value
    a.Name = value.Content[5].Value

    switch a.Type {
    case LawAgentType:
        a.Parameters = &LawParameters{}
    case AccountAgentType:
        a.Parameters = &AccountParameters{}
    case CarpenterAgentType:
        a.Parameters = &CarpenterParameters{}
    default:
        panic(fmt.Errorf("invalid agent type: %v", a.Type))
    }

    return value.Content[7].Decode(a.Parameters)
}

func main() {
    f, err := os.ReadFile("config.yaml")
    if err != nil {
        log.Panic("Error reading config file")
    }

    var agents Agents
    err = yaml.Unmarshal(f, &agents)
    if err != nil {
        fmt.Println(err.Error())
    }

    fmt.Println(agents)
}

as you can see Agent's fields have no more tags, because unmarshalling logic is moved into UnmarshalYAML method, but NOTE that order of agent fields in config.yaml file must be the same, because in my example identifier, type, name, parameters are taken by index from Content slice. You can add your logic using value's fields to determine each fields value and do not depend on order from config file.

Also, you can use type switch construct to detect parameters type

for _, a := range agents.Agent {
    switch params := a.Parameters.(type) {
    case *LawParameters:
        fmt.Println("Law", params.Speciality, params.SubSpeciality)
    case *AccountParameters:
        fmt.Println("Account", params.Expertise)
    case *CarpenterParameters:
        fmt.Println("Carpenter", params.Tool)
    }
}

Upvotes: 0

Related Questions