Repository: tableauio/tableau
Pull request #91 (2024-03-16 03:38:34)

store: provide protobuf message store package for public access
related issue: https://github.com/tableauio/loader/issues/36

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/tableauio/tableau/pull/91?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) Report
Attention: Patch coverage is `75.59055%` with `31 lines` in your changes are missing coverage. Please review.
> Project coverage is 62.29%. Comparing base [(`36d980f`)](https://app.codecov.io/gh/tableauio/tableau/commit/36d980f072a2aff234b8f4710db388313377ea42?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) to head [(`c2996ca`)](https://app.codecov.io/gh/tableauio/tableau/pull/91?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio).

| [Files](https://app.codecov.io/gh/tableauio/tableau/pull/91?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) | Patch % | Lines |
|---|---|---|
| [store/store.go](https://app.codecov.io/gh/tableauio/tableau/pull/91?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio#diff-c3RvcmUvc3RvcmUuZ28=) | 58.33% | [16 Missing and 4 partials :warning: ](https://app.codecov.io/gh/tableauio/tableau/pull/91?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) |
| [store/options.go](https://app.codecov.io/gh/tableauio/tableau/pull/91?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio#diff-c3RvcmUvb3B0aW9ucy5nbw==) | 72.41% | [8 Missing :warning: ](https://app.codecov.io/gh/tableauio/tableau/pull/91?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) |
| [internal/confgen/util.go](https://app.codecov.io/gh/tableauio/tableau/pull/91?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio#diff-aW50ZXJuYWwvY29uZmdlbi91dGlsLmdv) | 83.33% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/tableauio/tableau/pull/91?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master      #91      +/-   ##
==========================================
+ Coverage   62.14%   62.29%   +0.14%     
==========================================
  Files          37       39       +2     
  Lines        5708     5736      +28     
==========================================
+ Hits         3547     3573      +26     
- Misses       1786     1792       +6     
+ Partials      375      371       -4     
```

| [Flag](https://app.codecov.io/gh/tableauio/tableau/pull/91/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/tableauio/tableau/pull/91/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio) | `62.29% <75.59%> (+0.14%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/tableauio/tableau/pull/91?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=tableauio).


Patch:
diff --git a/internal/confgen/mexporter/mexporter.go b/internal/confgen/mexporter/mexporter.go
deleted file mode 100644
index 11655e4..0000000
--- a/internal/confgen/mexporter/mexporter.go
+++ /dev/null
@@ -1,100 +0,0 @@
-// mexporter is the message exporter package, which can export one
-// single message to different formts: JSON, Text, and Bin.
-package mexporter
-
-import (
-	"io/ioutil"
-	"os"
-	"path/filepath"
-
-	"github.com/pkg/errors"
-	"github.com/tableauio/tableau/format"
-	"github.com/tableauio/tableau/log"
-	"github.com/tableauio/tableau/options"
-	"github.com/tableauio/tableau/xerrors"
-	"google.golang.org/protobuf/proto"
-)
-
-type messageExporter struct {
-	name      string
-	msg       proto.Message
-	outputDir string
-	outputOpt *options.ConfOutputOption
-	// wsOpts    *tableaupb.WorksheetOptions
-}
-
-func New(name string, msg proto.Message, outputDir string, outputOpt *options.ConfOutputOption) *messageExporter {
-	return &messageExporter{
-		name:      name,
-		msg:       msg,
-		outputOpt: outputOpt,
-		outputDir: filepath.Join(outputDir, outputOpt.Subdir),
-	}
-}
-
-// Export exports the message to the specified one or multiple forma(s).
-func (x *messageExporter) Export() error {
-	formats := format.OutputFormats
-	if len(x.outputOpt.Formats) != 0 {
-		formats = x.outputOpt.Formats
-	}
-
-	for _, fmt := range formats {
-		err := x.export(fmt)
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// export marshals the message to the specified format and writes it to the
-// specified file.
-func (x *messageExporter) export(fmt format.Format) error {
-	filename := x.name
-	var out []byte
-	var err error
-	switch fmt {
-	case format.JSON:
-		filename += format.JSONExt
-		options := &MarshalOptions{
-			Pretty:          x.outputOpt.Pretty,
-			EmitUnpopulated: x.outputOpt.EmitUnpopulated,
-			UseProtoNames:   x.outputOpt.UseProtoNames,
-			UseEnumNumbers:  x.outputOpt.UseEnumNumbers,
-		}
-		out, err = marshalToJSON(x.msg, options)
-		if err != nil {
-			return errors.Wrapf(err, "failed to export %s to JSON", x.name)
-		}
-	case format.Text:
-		filename += format.TextExt
-		out, err = marshalToText(x.msg, x.outputOpt.Pretty)
-		if err != nil {
-			return errors.Wrapf(err, "failed to export %s to Text", x.name)
-		}
-	case format.Bin:
-		filename += format.BinExt
-		out, err = marshalToBin(x.msg)
-		if err != nil {
-			return errors.Wrapf(err, "failed to export %s to Bin", x.name)
-		}
-	default:
-		return errors.Errorf("unknown output format: %v", fmt)
-	}
-
-	// prepare output dir
-	if err := os.MkdirAll(x.outputDir, 0700); err != nil {
-		return xerrors.WrapKV(err, "OutputDir", x.outputDir)
-	}
-
-	// write file
-	fpath := filepath.Join(x.outputDir, filename)
-	err = ioutil.WriteFile(fpath, out, 0644)
-	if err != nil {
-		return errors.Wrapf(err, "failed to write file: %s", fpath)
-	}
-	// out.WriteTo(os.Stdout)
-	log.Infof("%18s: %s", "generated conf", filename)
-	return nil
-}
diff --git a/internal/confgen/parser.go b/internal/confgen/parser.go
index 18c5e53..30dc2c2 100644
--- a/internal/confgen/parser.go
+++ b/internal/confgen/parser.go
@@ -8,7 +8,6 @@ import (
 	"sync"
 
 	"github.com/iancoleman/strcase"
-	"github.com/tableauio/tableau/internal/confgen/mexporter"
 	"github.com/tableauio/tableau/internal/confgen/prop"
 	"github.com/tableauio/tableau/internal/importer"
 	"github.com/tableauio/tableau/internal/importer/book"
@@ -55,11 +54,7 @@ func (x *sheetExporter) ScatterAndExport(info *SheetInfo, impInfos ...importer.I
 			// exported conf name pattern is : <BookName>_<SheetName>
 			sheetName := getRealSheetName(info, impInfo)
 			name := fmt.Sprintf("%s_%s", impInfo.BookName(), sheetName)
-			exporter := mexporter.New(name, protomsg, x.OutputDir, x.OutputOpt)
-			if err := exporter.Export(); err != nil {
-				return err
-			}
-			return nil
+			return storeMessage(protomsg, name, x.OutputDir, x.OutputOpt)
 		})
 	}
 	if err := eg.Wait(); err != nil {
@@ -74,11 +69,7 @@ func (x *sheetExporter) MergeAndExport(info *SheetInfo, impInfos ...importer.Imp
 	if err != nil {
 		return err
 	}
-	exporter := mexporter.New(string(info.MD.Name()), protomsg, x.OutputDir, x.OutputOpt)
-	if err := exporter.Export(); err != nil {
-		return err
-	}
-	return nil
+	return storeMessage(protomsg, string(info.MD.Name()), x.OutputDir, x.OutputOpt)
 }
 
 type oneMsg struct {
diff --git a/internal/confgen/util.go b/internal/confgen/util.go
index 4ab9851..239f1d9 100644
--- a/internal/confgen/util.go
+++ b/internal/confgen/util.go
@@ -12,7 +12,9 @@ import (
 	"github.com/tableauio/tableau/internal/types"
 	"github.com/tableauio/tableau/internal/xproto"
 	"github.com/tableauio/tableau/log"
+	"github.com/tableauio/tableau/options"
 	"github.com/tableauio/tableau/proto/tableaupb"
+	"github.com/tableauio/tableau/store"
 	"github.com/tableauio/tableau/xerrors"
 	"google.golang.org/protobuf/proto"
 	"google.golang.org/protobuf/reflect/protoreflect"
@@ -233,3 +235,25 @@ func loadProtoRegistryFiles(protoPackage string, protoPaths []string, protoFiles
 	}
 	return xproto.NewFiles(protoPaths, protoFiles, excludeProtoFiles...)
 }
+
+// storeMessage stores a message to one or multiple file formats.
+func storeMessage(msg proto.Message, name string, outputDir string, opt *options.ConfOutputOption) error {
+	outputDir = filepath.Join(outputDir, opt.Subdir)
+	formats := format.OutputFormats
+	if len(opt.Formats) != 0 {
+		formats = opt.Formats
+	}
+	for _, fmt := range formats {
+		err := store.Store(msg, outputDir, fmt,
+			store.Name(name),
+			store.Pretty(opt.Pretty),
+			store.EmitUnpopulated(opt.EmitUnpopulated),
+			store.UseProtoNames(opt.UseProtoNames),
+			store.UseEnumNumbers(opt.UseEnumNumbers),
+		)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
diff --git a/load/load.go b/load/load.go
index 6da17a8..e883652 100644
--- a/load/load.go
+++ b/load/load.go
@@ -1,3 +1,5 @@
+// Package load provides functions to load a protobuf message from
+// different formats: json, bin, txt, xlsx, csv, and xml.
 package load
 
 import (
@@ -16,83 +18,7 @@ import (
 	"google.golang.org/protobuf/reflect/protoregistry"
 )
 
-type Options struct {
-	// Location represents the collection of time offsets in use in
-	// a geographical area.
-	//
-	// If the name is "" or "UTC", LoadLocation returns UTC.
-	// If the name is "Local", LoadLocation returns Local.
-	// Default: "Local".
-	LocationName string
-	// IgnoreUnknownFields signifies whether to ignore unknown JSON fields
-	// during parsing.
-	// Default: false.
-	IgnoreUnknownFields bool
-	// SubdirRewrites rewrites subdir paths (relative to workbook name option
-	// in .proto file).
-	// Default: nil.
-	SubdirRewrites map[string]string
-	// Paths maps each messager name to a corresponding config file path.
-	// If a messager name is existed, then the messager will be parsed from
-	// the config file directly.
-	// NOTE: only JSON, bin, and text formats are supported.
-	// Default: nil.
-	Paths map[string]string
-}
-
-// Option is the functional option type.
-type Option func(*Options)
-
-// LocationName sets TZ location name for parsing datetime format.
-func LocationName(name string) Option {
-	return func(opts *Options) {
-		opts.LocationName = name
-	}
-}
-
-// IgnoreUnknownFields ignores unknown JSON fields during parsing.
-func IgnoreUnknownFields() Option {
-	return func(opts *Options) {
-		opts.IgnoreUnknownFields = true
-	}
-}
-
-// SubdirRewrites rewrites subdir paths (relative to workbook name option
-// in .proto file).
-func SubdirRewrites(subdirRewrites map[string]string) Option {
-	return func(opts *Options) {
-		opts.SubdirRewrites = subdirRewrites
-	}
-}
-
-// Paths maps each messager name to a corresponding config file path.
-// If a messager name is existed, then the messager will be parsed from
-// the config file directly.
-func Paths(paths map[string]string) Option {
-	return func(opts *Options) {
-		opts.Paths = paths
-	}
-}
-
-// newDefault returns a default Options.
-func newDefault() *Options {
-	return &Options{
-		LocationName: "Local",
-	}
-}
-
-// ParseOptions parses functional options and merge them to default Options.
-func ParseOptions(setters ...Option) *Options {
-	// Default Options
-	opts := newDefault()
-	for _, setter := range setters {
-		setter(opts)
-	}
-	return opts
-}
-
-// Load reads file content from the specified directory and format,
-// and then fills the provided message.
+// Load fills message from file in the specified directory and format.
 func Load(msg proto.Message, dir string, fmt format.Format, options ...Option) error {
 	if format.IsInputFormat(fmt) {
 		return loadOrigin(msg, dir, options...)
diff --git a/load/options.go b/load/options.go
new file mode 100644
index 0000000..33168b5
--- /dev/null
+++ b/load/options.go
@@ -0,0 +1,76 @@
+package load
+
+type Options struct {
+	// Location represents the collection of time offsets in use in
+	// a geographical area.
+	//
+	// If the name is "" or "UTC", LoadLocation returns UTC.
+	// If the name is "Local", LoadLocation returns Local.
+	// Default: "Local".
+	LocationName string
+	// IgnoreUnknownFields signifies whether to ignore unknown JSON fields
+	// during parsing.
+	// Default: false.
+	IgnoreUnknownFields bool
+	// SubdirRewrites rewrites subdir paths (relative to workbook name option
+	// in .proto file).
+	// Default: nil.
+	SubdirRewrites map[string]string
+	// Paths maps each messager name to a corresponding config file path.
+	// If a messager name is existed, then the messager will be parsed from
+	// the config file directly.
+	// NOTE: only JSON, bin, and text formats are supported.
+	// Default: nil.
+	Paths map[string]string
+}
+
+// Option is the functional option type.
+type Option func(*Options)
+
+// LocationName sets TZ location name for parsing datetime format.
+func LocationName(name string) Option {
+	return func(opts *Options) {
+		opts.LocationName = name
+	}
+}
+
+// IgnoreUnknownFields ignores unknown JSON fields during parsing.
+func IgnoreUnknownFields() Option {
+	return func(opts *Options) {
+		opts.IgnoreUnknownFields = true
+	}
+}
+
+// SubdirRewrites rewrites subdir paths (relative to workbook name option
+// in .proto file).
+func SubdirRewrites(subdirRewrites map[string]string) Option {
+	return func(opts *Options) {
+		opts.SubdirRewrites = subdirRewrites
+	}
+}
+
+// Paths maps each messager name to a corresponding config file path.
+// If a messager name is existed, then the messager will be parsed from
+// the config file directly.
+func Paths(paths map[string]string) Option {
+	return func(opts *Options) {
+		opts.Paths = paths
+	}
+}
+
+// newDefault returns a default Options.
+func newDefault() *Options {
+	return &Options{
+		LocationName: "Local",
+	}
+}
+
+// ParseOptions parses functional options and merge them to default Options.
+func ParseOptions(setters ...Option) *Options {
+	// Default Options
+	opts := newDefault()
+	for _, setter := range setters {
+		setter(opts)
+	}
+	return opts
+}
diff --git a/internal/confgen/mexporter/marshal.go b/store/marshal.go
similarity index 88%
rename from internal/confgen/mexporter/marshal.go
rename to store/marshal.go
index 9337cd3..02af832 100644
--- a/internal/confgen/mexporter/marshal.go
+++ b/store/marshal.go
@@ -1,4 +1,4 @@
-package mexporter
+package store
 
 import (
 	"bytes"
@@ -47,9 +47,9 @@ type MarshalOptions struct {
 	UseEnumNumbers bool
 }
 
-// marshalToJSON marshals the given proto.Message in the JSON format.
+// MarshalToJSON marshals the given proto.Message in the JSON format.
 // You can depend on the output being stable.
-func marshalToJSON(msg proto.Message, options *MarshalOptions) (out []byte, err error) {
+func MarshalToJSON(msg proto.Message, options *MarshalOptions) (out []byte, err error) {
 	opts := protojson.MarshalOptions{
 		EmitUnpopulated: options.EmitUnpopulated,
 		UseProtoNames:   options.UseProtoNames,
@@ -78,9 +78,9 @@ func marshalToJSON(msg proto.Message, options *MarshalOptions) (out []byte, err
 	return stableJSON.Bytes(), nil
 }
 
-// marshalToText marshals the given proto.Message in the text (textproto) format.
+// MarshalToText marshals the given proto.Message in the text (textproto) format.
 // You can depend on the output being stable.
-func marshalToText(msg proto.Message, pretty bool) (out []byte, err error) {
+func MarshalToText(msg proto.Message, pretty bool) (out []byte, err error) {
 	if pretty {
 		opts := prototext.MarshalOptions{
 			Multiline: true,
@@ -111,9 +111,9 @@ func marshalToText(msg proto.Message, pretty bool) (out []byte, err error) {
 	return []byte(text), nil
 }
 
-// marshalToBin marshals the given proto.Message in the wire (binary) format.
+// MarshalToBin marshals the given proto.Message in the wire (binary) format.
 // You can depend on the output being stable.
-func marshalToBin(msg proto.Message) (out []byte, err error) {
+func MarshalToBin(msg proto.Message) (out []byte, err error) {
 	// protobuf does not offer a canonical output today, so this format is not
 	// guaranteed to match deterministic output from other protobuf libraries.
 	// In addition, unknown fields may cause inconsistent output for otherwise
diff --git a/store/options.go b/store/options.go
new file mode 100644
index 0000000..db0ad5a
--- /dev/null
+++ b/store/options.go
@@ -0,0 +1,98 @@
+package store
+
+type Options struct {
+	// Specify output file name (without file extension).
+	//
+	// Default: "".
+	Name string
+	// Output pretty format of JSON and Text, with multiline and indent.
+	//
+	// Default: false.
+	Pretty bool
+
+	// EmitUnpopulated specifies whether to emit unpopulated fields. It does not
+	// emit unpopulated oneof fields or unpopulated extension fields.
+	// The JSON value emitted for unpopulated fields are as follows:
+	//  ╔═══════╤════════════════════════════╗
+	//  ║ JSON  │ Protobuf field             ║
+	//  ╠═══════╪════════════════════════════╣
+	//  ║ false │ proto3 boolean fields      ║
+	//  ║ 0     │ proto3 numeric fields      ║
+	//  ║ ""    │ proto3 string/bytes fields ║
+	//  ║ null  │ proto2 scalar fields       ║
+	//  ║ null  │ message fields             ║
+	//  ║ []    │ list fields                ║
+	//  ║ {}    │ map fields                 ║
+	//  ╚═══════╧════════════════════════════╝
+	//
+	// NOTE: worksheet with FieldPresence set as true ignore this option.
+	//
+	// Refer: https://github.com/protocolbuffers/protobuf/blob/main/docs/field_presence.md
+	//
+	// Default: false.
+	EmitUnpopulated bool
+
+	// UseProtoNames uses proto field name instead of lowerCamelCase name in JSON
+	// field names.
+	UseProtoNames bool
+
+	// UseEnumNumbers emits enum values as numbers.
+	UseEnumNumbers bool
+}
+
+// Option is the functional option type.
+type Option func(*Options)
+
+// Name specifies the output file name (without file extension).
+func Name(v string) Option {
+	return func(opts *Options) {
+		opts.Name = v
+	}
+}
+
+// Pretty specifies whether to prettify JSON and Text output with
+// multiline and indent.
+func Pretty(v bool) Option {
+	return func(opts *Options) {
+		opts.Pretty = v
+	}
+}
+
+// EmitUnpopulated specifies whether to emit unpopulated fields.
+func EmitUnpopulated(v bool) Option {
+	return func(opts *Options) {
+		opts.EmitUnpopulated = v
+	}
+}
+
+// UseProtoNames specifies whether to use proto field name instead of
+// lowerCamelCase name in
+// JSON field names.
+func UseProtoNames(v bool) Option {
+	return func(opts *Options) {
+		opts.UseProtoNames = v
+	}
+}
+
+// UseEnumNumbers specifies whether to emit enum values as numbers for
+// JSON field values.
+func UseEnumNumbers(v bool) Option {
+	return func(opts *Options) {
+		opts.UseEnumNumbers = v
+	}
+}
+
+// newDefault returns a default Options.
+func newDefault() *Options {
+	return &Options{}
+}
+
+// ParseOptions parses functional options and merge them to default Options.
+func ParseOptions(setters ...Option) *Options {
+	// Default Options
+	opts := newDefault()
+	for _, setter := range setters {
+		setter(opts)
+	}
+	return opts
+}
diff --git a/store/store.go b/store/store.go
new file mode 100644
index 0000000..8f7fd79
--- /dev/null
+++ b/store/store.go
@@ -0,0 +1,72 @@
+// Package store provides functions to store a protobuf message to
+// different formats: json, bin, and txt.
+package store
+
+import (
+	"os"
+	"path/filepath"
+
+	"github.com/pkg/errors"
+	"github.com/tableauio/tableau/format"
+	"github.com/tableauio/tableau/log"
+	"github.com/tableauio/tableau/xerrors"
+	"google.golang.org/protobuf/proto"
+)
+
+// Store stores protobuf message to file in the specified directory and format.
+// Available formats: JSON, Bin, and Text.
+func Store(msg proto.Message, dir string, fmt format.Format, options ...Option) error {
+	opts := ParseOptions(options...)
+	var name string
+	if opts.Name != "" {
+		name = opts.Name // name specified explicitly
+	} else {
+		name = string(msg.ProtoReflect().Descriptor().Name())
+	}
+	filename := name
+	var out []byte
+	var err error
+	switch fmt {
+	case format.JSON:
+		filename += format.JSONExt
+		options := &MarshalOptions{
+			Pretty:          opts.Pretty,
+			EmitUnpopulated: opts.EmitUnpopulated,
+			UseProtoNames:   opts.UseProtoNames,
+			UseEnumNumbers:  opts.UseEnumNumbers,
+		}
+		out, err = MarshalToJSON(msg, options)
+		if err != nil {
+			return errors.Wrapf(err, "failed to export %s to JSON", name)
+		}
+	case format.Text:
+		filename += format.TextExt
+		out, err = MarshalToText(msg, opts.Pretty)
+		if err != nil {
+			return errors.Wrapf(err, "failed to export %s to Text", name)
+		}
+	case format.Bin:
+		filename += format.BinExt
+		out, err = MarshalToBin(msg)
+		if err != nil {
+			return errors.Wrapf(err, "failed to export %s to Bin", name)
+		}
+	default:
+		return errors.Errorf("unknown output format: %v", fmt)
+	}
+
+	// prepare output dir
+	if err := os.MkdirAll(dir, 0700); err != nil {
+		return xerrors.Errorf(`create output dir "%s" failed: %s`, dir, err)
+	}
+
+	// write file
+	fpath := filepath.Join(dir, filename)
+	err = os.WriteFile(fpath, out, 0644)
+	if err != nil {
+		return xerrors.Errorf(`write file "%s" failed: %s`, fpath, err)
+	}
+	// out.WriteTo(os.Stdout)
+	log.Infof("%18s: %s", "generated conf", filename)
+	return nil
+}


Test patch:
diff --git a/internal/confgen/mexporter/mexporter_test.go b/internal/confgen/mexporter/mexporter_test.go
deleted file mode 100644
index 466516b..0000000
--- a/internal/confgen/mexporter/mexporter_test.go
+++ /dev/null
@@ -1,49 +0,0 @@
-// mexporter is the message exporter package, which can export one
-// single message to different formts: JSON, Text, and Bin.
-
-package mexporter
-
-import (
-	"testing"
-
-	"github.com/tableauio/tableau/options"
-	"github.com/tableauio/tableau/proto/tableaupb/unittestpb"
-)
-
-var testMessageExporter *messageExporter
-
-func init() {
-	itemConf = &unittestpb.ItemConf{
-		ItemMap: map[uint32]*unittestpb.Item{
-			1: {Id: 1, Num: 10},
-			2: {Id: 2, Num: 20},
-			3: {Id: 3, Num: 30},
-		},
-	}
-	outputOpt := &options.ConfOutputOption{
-		Subdir: "conf",
-		Pretty: true,
-	}
-	testMessageExporter = New("ItemConf", itemConf, "_out/", outputOpt)
-}
-
-func Test_messageExporter_Export(t *testing.T) {
-	tests := []struct {
-		name    string
-		x       *messageExporter
-		wantErr bool
-	}{
-		{
-			name:    "export-item-conf",
-			x:       testMessageExporter,
-			wantErr: false,
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			if err := tt.x.Export(); (err != nil) != tt.wantErr {
-				t.Errorf("messageExporter.Export() error = %v, wantErr %v", err, tt.wantErr)
-			}
-		})
-	}
-}
diff --git a/internal/confgen/util_test.go b/internal/confgen/util_test.go
index e8096c5..7c84993 100644
--- a/internal/confgen/util_test.go
+++ b/internal/confgen/util_test.go
@@ -1,6 +1,13 @@
 package confgen
 
-import "testing"
+import (
+	"testing"
+
+	"github.com/tableauio/tableau/format"
+	"github.com/tableauio/tableau/options"
+	"github.com/tableauio/tableau/proto/tableaupb/unittestpb"
+	"google.golang.org/protobuf/proto"
+)
 
 func Test_parseBookSpecifier(t *testing.T) {
 	type args struct {
@@ -66,3 +73,62 @@ func Test_parseBookSpecifier(t *testing.T) {
 		})
 	}
 }
+
+func Test_storeMessage(t *testing.T) {
+	itemConf := &unittestpb.ItemConf{
+		ItemMap: map[uint32]*unittestpb.Item{
+			1: {Id: 1, Num: 10},
+			2: {Id: 2, Num: 20},
+			3: {Id: 3, Num: 30},
+		},
+	}
+	type args struct {
+		msg       proto.Message
+		name      string
+		outputDir string
+		opt       *options.ConfOutputOption
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "export-item-conf",
+			args: args{
+				msg:       itemConf,
+				name:      "ItemConfAlias",
+				outputDir: "_out/",
+				opt: &options.ConfOutputOption{
+					Subdir:          "",
+					Formats:         []format.Format{"json"},
+					Pretty:          true,
+					EmitUnpopulated: true,
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "export-item-conf-subdir",
+			args: args{
+				msg:       itemConf,
+				name:      "",
+				outputDir: "_out/",
+				opt: &options.ConfOutputOption{
+					Subdir:          "subdir",
+					Formats:         nil,
+					Pretty:          true,
+					EmitUnpopulated: true,
+				},
+			},
+			wantErr: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := storeMessage(tt.args.msg, tt.args.name, tt.args.outputDir, tt.args.opt); (err != nil) != tt.wantErr {
+				t.Errorf("storeMessage() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}
diff --git a/internal/confgen/mexporter/marshal_test.go b/store/marshal_test.go
similarity index 85%
rename from internal/confgen/mexporter/marshal_test.go
rename to store/marshal_test.go
index e400ed2..0ea2068 100644
--- a/internal/confgen/mexporter/marshal_test.go
+++ b/store/marshal_test.go
@@ -1,4 +1,4 @@
-package mexporter
+package store
 
 import (
 	"testing"
@@ -20,7 +20,7 @@ func init() {
 	}
 }
 
-func Test_marshalToJSON(t *testing.T) {
+func Test_MarshalToJSON(t *testing.T) {
 	type args struct {
 		msg     proto.Message
 		options *MarshalOptions
@@ -77,9 +77,9 @@ func Test_marshalToJSON(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			gotOut, err := marshalToJSON(tt.args.msg, tt.args.options)
+			gotOut, err := MarshalToJSON(tt.args.msg, tt.args.options)
 			if (err != nil) != tt.wantErr {
-				t.Errorf("marshalToJSON() error = %v, wantErr %v", err, tt.wantErr)
+				t.Errorf("MarshalToJSON() error = %v, wantErr %v", err, tt.wantErr)
 				return
 			}
 			assert.EqualValues(t, tt.wantOut, gotOut)
@@ -87,7 +87,7 @@ func Test_marshalToJSON(t *testing.T) {
 	}
 }
 
-func Test_marshalToText(t *testing.T) {
+func Test_MarshalToText(t *testing.T) {
 	type args struct {
 		msg    proto.Message
 		pretty bool
@@ -139,9 +139,9 @@ item_map: {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			gotOut, err := marshalToText(tt.args.msg, tt.args.pretty)
+			gotOut, err := MarshalToText(tt.args.msg, tt.args.pretty)
 			if (err != nil) != tt.wantErr {
-				t.Errorf("marshalToText() error = %v, wantErr %v", err, tt.wantErr)
+				t.Errorf("MarshalToText() error = %v, wantErr %v", err, tt.wantErr)
 				return
 			}
 			assert.EqualValues(t, tt.wantOut, gotOut)
@@ -149,7 +149,7 @@ item_map: {
 	}
 }
 
-func Test_marshalToBin(t *testing.T) {
+func Test_MarshalToBin(t *testing.T) {
 	type args struct {
 		msg proto.Message
 	}
@@ -174,9 +174,9 @@ func Test_marshalToBin(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			gotOut, err := marshalToBin(tt.args.msg)
+			gotOut, err := MarshalToBin(tt.args.msg)
 			if (err != nil) != tt.wantErr {
-				t.Errorf("marshalToBin() error = %v, wantErr %v", err, tt.wantErr)
+				t.Errorf("MarshalToBin() error = %v, wantErr %v", err, tt.wantErr)
 				return
 			}
 			assert.EqualValues(t, tt.wantOut, gotOut)
diff --git a/store/store_test.go b/store/store_test.go
new file mode 100644
index 0000000..8163512
--- /dev/null
+++ b/store/store_test.go
@@ -0,0 +1,75 @@
+package store
+
+import (
+	"testing"
+
+	"github.com/tableauio/tableau/format"
+	"github.com/tableauio/tableau/proto/tableaupb/unittestpb"
+	"google.golang.org/protobuf/proto"
+)
+
+func TestStore(t *testing.T) {
+	itemConf = &unittestpb.ItemConf{
+		ItemMap: map[uint32]*unittestpb.Item{
+			1: {Id: 1, Num: 10},
+			2: {Id: 2, Num: 20},
+			3: {Id: 3, Num: 30},
+		},
+	}
+	type args struct {
+		msg     proto.Message
+		dir     string
+		fmt     format.Format
+		options []Option
+	}
+	tests := []struct {
+		name    string
+		args    args
+		wantErr bool
+	}{
+		{
+			name: "export-item-conf-json",
+			args: args{
+				msg: itemConf,
+				dir: "_out/",
+				fmt: "json",
+				options: []Option{
+					Pretty(true),
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "export-item-conf-json-alias",
+			args: args{
+				msg: itemConf,
+				dir: "_out/subdir/",
+				fmt: "json",
+				options: []Option{
+					Name("ItemConfAlias"),
+					UseProtoNames(true),
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "export-item-conf-txt",
+			args: args{
+				msg: itemConf,
+				dir: "_out/",
+				fmt: "txt",
+				options: []Option{
+					Pretty(true),
+				},
+			},
+			wantErr: false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if err := Store(tt.args.msg, tt.args.dir, tt.args.fmt, tt.args.options...); (err != nil) != tt.wantErr {
+				t.Errorf("Store() error = %v, wantErr %v", err, tt.wantErr)
+			}
+		})
+	}
+}