trpc.group/trpc-go/trpc-cmdline@v1.0.9/util/pb/protoc.go (about)

     1  // Tencent is pleased to support the open source community by making tRPC available.
     2  //
     3  // Copyright (C) 2023 THL A29 Limited, a Tencent company.
     4  // All rights reserved.
     5  //
     6  // If you have downloaded a copy of the tRPC source code from Tencent,
     7  // please note that tRPC source code is licensed under the  Apache 2.0 License,
     8  // A copy of the Apache 2.0 License is included in this file.
     9  
    10  // Package pb encapsulates the protoc execution logic.
    11  package pb
    12  
    13  import (
    14  	"errors"
    15  	"fmt"
    16  	"os"
    17  	"os/exec"
    18  	"path/filepath"
    19  	"sort"
    20  	"strings"
    21  
    22  	"trpc.group/trpc-go/trpc-cmdline/util/fs"
    23  	"trpc.group/trpc-go/trpc-cmdline/util/log"
    24  	"trpc.group/trpc-go/trpc-cmdline/util/paths"
    25  )
    26  
    27  // Constants definition.
    28  const (
    29  	ProtoTRPC              = "trpc/proto/trpc_options.proto"
    30  	ProtoValidate          = "trpc/validate/validate.proto"
    31  	ProtoProtocGenValidate = "validate/validate.proto" // https://github.com/bufbuild/protoc-gen-validate
    32  	ProtoSwagger           = "trpc/swagger/swagger.proto"
    33  
    34  	ProtoDir         = "protobuf"
    35  	TrpcImportPrefix = "trpc/"
    36  )
    37  
    38  // IsInternalProto tests if `fname` is internal.
    39  func IsInternalProto(fname string) bool {
    40  	if strings.HasPrefix(fname, "google/protobuf") ||
    41  		fname == ProtoTRPC ||
    42  		fname == ProtoValidate ||
    43  		fname == ProtoProtocGenValidate ||
    44  		fname == ProtoSwagger ||
    45  		strings.HasPrefix(fname, TrpcImportPrefix) {
    46  		return true
    47  	}
    48  	return false
    49  }
    50  
    51  // Protoc process `protofile` to generate *.pb.go, which is specified by `language`
    52  //
    53  // When using protoc, the following should also be taken into consideration:
    54  // 1. The pb file being processed may import other pb files, e.g., a.proto imports b.proto,
    55  // and the resulting a.pb.go file will call the initialization function in b.pb.go,
    56  // typically named file_${path_to}b_proto_init();
    57  // 2. When executing protoc, the options passed can have an impact on the generated code.
    58  // For instance, protoc -I path-to a.proto and protoc path-to/a.proto will generate different code,
    59  // with the difference being reflected in the function name file${path_to}_b_proto_init().
    60  // The former will generate an empty ${path_to} part, leading to compilation failure.
    61  //
    62  // How to avoid this problem?
    63  //   - The import statements in pb files may contain virtual path information,
    64  //     which needs to be determined based on the search path specified with -I.
    65  //   - When processing pb files with protoc, the pb file names must contain virtual path information.
    66  //     For example, it should be protoc path-to/a.proto instead of protoc -I path-to a.proto.
    67  //   - Additionally, it is essential that there exists a search path in protoc's search path list
    68  //     that is the parent path of the pb file being processed. This is because of how protoc resolves paths.
    69  //
    70  // About the use of optional labels in proto syntax3:
    71  // Compatibility logic needs to be implemented for different versions of protoc:
    72  //   - protoc (~, v3.12.0), pb syntax3 does not support optional labels
    73  //   - protoc [v3.12.0, v3.15.0), pb syntax3 supports optional labels,
    74  //     but the option --experimental_allow_proto3_optional needs to be added.
    75  //   - protoc v3.15.0+, optional labels in pb syntax3 syntax are parsed by default.
    76  //
    77  // ------------------------------------------------------------------------------------------------------------------
    78  //
    79  // Regarding the issue of output paths for pb.go files:
    80  // The paths=source_relative option controls the output filenames, not the import paths.
    81  // The proto compiler associates an import path with each .proto file. When a.proto imports b.proto,
    82  // the import path is used to determine what (if any) import statement to put in a.pb.go. You can set the import paths
    83  // with go_package options in the .proto files, or with --go_opt=M<filename>=<import_path> on the command line.
    84  //
    85  // The proto compiler generates a .pb.go file for each .proto file. There are several ways in which the output
    86  // directory may be determined. For example, if source/a.proto has an import path of example.com/m/foo:
    87  //
    88  // --go_opt=paths=import: Import path; e.g., example.com/m/foo/a.pb.go
    89  // --go_opt=paths=source_relative: Source path; e.g., source/a.pb.go
    90  // --go_opt=module=example.com/m: Path relative to the module flag; e.g., foo/a.pb.go
    91  //
    92  // In the worst case, if none of these suit your needs, you can always generate into a temporary directory and copy the
    93  // file into the desired location. Neither the paths nor module flags have any effect on the contents of the generated
    94  // files.
    95  func Protoc(protodirs []string, protofile, lang, outputdir string, opts ...Option) error {
    96  	options := options{
    97  		pb2ImportPath:  make(map[string]string),
    98  		pkg2ImportPath: make(map[string]string),
    99  	}
   100  	for _, o := range opts {
   101  		o(&options)
   102  	}
   103  
   104  	protocArgs, err := genProtocArgs(protodirs, protofile, lang, outputdir, options)
   105  	if err != nil {
   106  		return fmt.Errorf("generate protoc args err: %w", err)
   107  	}
   108  
   109  	importPath, ok := options.pb2ImportPath[protofile]
   110  	if ok {
   111  		defer movePbGoFile(protocArgs.argsGoOut, importPath, protocArgs.baseDir, protofile)
   112  	}
   113  
   114  	var args []string
   115  	args = append(args, protocArgs.argsProtoPath...)
   116  	args = append(args, protocArgs.argsGoOut)
   117  	args = append(args, protofile)
   118  	if protocArgs.descriptorSetIn != "" {
   119  		args = append(args, protocArgs.descriptorSetIn)
   120  	}
   121  
   122  	// pb3 supports "optional" and other labels.
   123  	args, err = makePb3Labels(args)
   124  	if err != nil {
   125  		panic(err)
   126  	}
   127  
   128  	return execProtocCommand(args)
   129  }
   130  
   131  func genRelPathFromWdWithDirs(protodirs []string, protofile, wd string) (string, error) {
   132  	for _, dir := range protodirs {
   133  		pbPath := filepath.Join(dir, protofile)
   134  		if fin, err := os.Lstat(pbPath); err != nil || fin.IsDir() {
   135  			// If there is an error getting file information or the path is a directory,
   136  			// continue searching for the next one.
   137  			continue
   138  		}
   139  
   140  		rel, err := genRelPathFromWd(pbPath, wd)
   141  		if err != nil {
   142  			return "", err
   143  		}
   144  
   145  		return rel, nil
   146  	}
   147  	return "", errors.New("no valid relative path found, please check if the file exists")
   148  }
   149  
   150  func genRelPathFromWd(protofile, wd string) (string, error) {
   151  	absWd, err := filepath.Abs(wd)
   152  	if err != nil {
   153  		return "", fmt.Errorf("failed to obtain the absolute path for %s: %w", wd, err)
   154  	}
   155  	absPbFile, err := filepath.Abs(protofile)
   156  	if err != nil {
   157  		return "", fmt.Errorf("failed to obtain the absolute path for %s: %w", protofile, err)
   158  	}
   159  	relPath, err := filepath.Rel(absWd, absPbFile)
   160  	if err != nil {
   161  		log.Error("error getting the relative path from %s to %s", absWd, absPbFile)
   162  		return "", fmt.Errorf("Error getting the relative path: %w", err)
   163  	}
   164  	return relPath, nil
   165  }
   166  
   167  func execProtocCommand(args []string) error {
   168  	log.Debug("protoc %s", strings.Join(args, " "))
   169  
   170  	cmd := exec.Command("protoc", args...)
   171  	if output, err := cmd.CombinedOutput(); err != nil {
   172  		msg := `Explicit 'optional' labels are disallowed in the Proto3 syntax`
   173  		str := strings.Join(cmd.Args, " ")
   174  		if strings.Contains(string(output), msg) {
   175  			return fmt.Errorf("run command: `%s`, error: %s...upgrade `protoc` to v3.15.0+", str, string(output))
   176  		}
   177  		return fmt.Errorf("run command: `%s`, error: %s", str, string(output))
   178  	}
   179  
   180  	return nil
   181  }
   182  
   183  type protocArgs struct {
   184  	baseDir         string
   185  	argsProtoPath   []string
   186  	argsGoOut       string
   187  	descriptorSetIn string
   188  }
   189  
   190  func genProtocArgs(protodirs []string, protofile, lang, outputdir string, options options) (*protocArgs, error) {
   191  	baseDir, baseName := filepath.Split(protofile)
   192  	outputdir = strings.TrimSuffix(filepath.Clean(outputdir), "/"+filepath.Clean(baseDir))
   193  
   194  	pb2ImportPath := options.pb2ImportPath
   195  	dirs, err := protoSearchDirs(pb2ImportPath)
   196  	if err != nil {
   197  		return nil, fmt.Errorf("proto search dirs err: %w", err)
   198  	}
   199  	protodirs = append(protodirs, dirs...)
   200  
   201  	// make --go_out
   202  	argsGoOut := makeProtocOut(pb2ImportPath, lang, outputdir, options)
   203  	args := &protocArgs{baseDir, nil, argsGoOut, ""}
   204  	if options.descriptorSetIn == "" { // --proto_path and --descriptor_set_in cannot coexist.
   205  		p, err := paths.Locate(baseName, protodirs...)
   206  		if err != nil {
   207  			return nil, fmt.Errorf("paths locate err: %w", err)
   208  		}
   209  		p = strings.TrimSuffix(filepath.Clean(p), filepath.Clean(baseDir))
   210  		// make --proto_path
   211  		args.argsProtoPath, err = makeProtoPath(protodirs, p, options)
   212  		if err != nil {
   213  			return nil, fmt.Errorf("make proto path err: %w", err)
   214  		}
   215  		return args, nil
   216  	}
   217  	args.descriptorSetIn = "--descriptor_set_in=" + options.descriptorSetIn
   218  	return args, nil
   219  }
   220  
   221  func makePb3Labels(args []string) ([]string, error) {
   222  	// proto3 allow optional
   223  	v, err := protocVersion()
   224  	if err != nil {
   225  		return nil, err
   226  	}
   227  	if CheckVersionGreaterThanOrEqualTo(v, "v3.15.0") {
   228  		// enabled by default, and no longer require --experimental_allow_proto3_optional flag
   229  	} else if CheckVersionGreaterThanOrEqualTo(v, "v3.12.0") {
   230  		// [experimental] adding the "optional" field label, need passing --experimental_allow_proto3_optional flag
   231  		args = append(args, "--experimental_allow_proto3_optional")
   232  	} else if CheckVersionGreaterThanOrEqualTo(v, "v3.6.0") {
   233  		// Not supported, no need for special settings.
   234  	} else {
   235  		// Not supported and version is below the recommended version of trpc.
   236  		log.Info("protoc version too low, please upgrade it")
   237  	}
   238  	return args, nil
   239  }
   240  
   241  func movePbGoFile(argsGoOut, pkg, baseDir, protofile string) {
   242  	v := strings.Split(argsGoOut, ":")
   243  	if len(v) != 2 {
   244  		return
   245  	}
   246  	vv := strings.Split(v[1], "stub/")
   247  	if len(vv) != 2 {
   248  		return
   249  	}
   250  	if vv[1] == pkg {
   251  		pdir := filepath.Join(v[1], baseDir)
   252  		target := filepath.Join(pdir, fs.BaseNameWithoutExt(protofile)+".pb.go")
   253  		fs.Move(target, v[1])
   254  
   255  		idx := strings.Index(baseDir, "/")
   256  		if idx != -1 {
   257  			path := filepath.Join(v[1], baseDir[0:idx])
   258  			os.RemoveAll(path)
   259  		}
   260  	}
   261  }
   262  
   263  func protoSearchDirs(pb2ImportPath map[string]string) ([]string, error) {
   264  	var protodirs []string
   265  	var err error
   266  
   267  	// locate trpc.proto
   268  	protodirs, err = trpcProtoSearchDir(pb2ImportPath, protodirs)
   269  	if err != nil {
   270  		return nil, err
   271  	}
   272  
   273  	// locate validate.proto
   274  	protodirs, err = validateProtoSearchDir(pb2ImportPath, protodirs)
   275  	if err != nil {
   276  		return nil, err
   277  	}
   278  
   279  	// locate protobuf dir
   280  	if isTrpcProtoImported(pb2ImportPath) {
   281  		pbDir, err := paths.Locate(ProtoDir)
   282  		if err == nil {
   283  			// If the trpc system pb directory is found, it is imported, otherwise it is skipped.
   284  			protodirs = append(protodirs, filepath.Join(pbDir, "protobuf"))
   285  		}
   286  	}
   287  
   288  	return protodirs, nil
   289  }
   290  
   291  func validateProtoSearchDir(pb2ImportPath map[string]string, protodirs []string) ([]string, error) {
   292  	_, secvdep := pb2ImportPath[ProtoValidate]
   293  	if secvdep {
   294  		secvp, err := paths.Locate(ProtoValidate)
   295  		if err != nil {
   296  			return nil, err
   297  		}
   298  		protodirs = append(protodirs, secvp)
   299  	}
   300  	return protodirs, nil
   301  }
   302  
   303  func trpcProtoSearchDir(pb2ImportPath map[string]string, protodirs []string) ([]string, error) {
   304  	_, dep := pb2ImportPath[ProtoTRPC]
   305  	if dep {
   306  		p, err := paths.Locate(ProtoTRPC)
   307  		if err != nil {
   308  			return nil, err
   309  		}
   310  		protodirs = append(protodirs, p)
   311  	}
   312  	return protodirs, nil
   313  }
   314  
   315  func isTrpcProtoImported(pbpkgMapping map[string]string) bool {
   316  	for k := range pbpkgMapping {
   317  		if strings.HasPrefix(k, TrpcImportPrefix) {
   318  			return true
   319  		}
   320  	}
   321  
   322  	return false
   323  }
   324  
   325  func makeProtocOut(pb2ImportPath map[string]string, language, outputdir string, options options) string {
   326  	pbpkg := genPbpkg(pb2ImportPath)
   327  	argsGoOut := makeProtocOutByLanguage(language, pbpkg, outputdir)
   328  
   329  	if options.validationEnabled {
   330  		_, ok := options.pkg2ImportPath["validate"]
   331  		if ok {
   332  			argsGoOut = fixProtocOut("validate", argsGoOut, language)
   333  		}
   334  	} else if options.secvEnabled {
   335  		_, ok := options.pkg2ImportPath["validate"]
   336  		secvOut := "secv"
   337  		if !ok {
   338  			_, ok = options.pkg2ImportPath["trpc.v2.validate"]
   339  			secvOut = "secv-v2"
   340  		}
   341  		if ok {
   342  			argsGoOut = fixProtocOut(secvOut, argsGoOut, language)
   343  		}
   344  	}
   345  	return argsGoOut
   346  }
   347  
   348  func makeProtocOutByLanguage(language string, pbpkg string, outputdir string) string {
   349  	languageToOut := map[string]string{
   350  		"go": fmt.Sprintf("--%s_out=paths=source_relative%s:%s", language, pbpkg, outputdir),
   351  	}
   352  
   353  	out := languageToOut[language]
   354  	if len(out) > 0 {
   355  		return out
   356  	}
   357  
   358  	// Other unexpected programming languages.
   359  	_ = os.MkdirAll(outputdir, os.ModePerm)
   360  	if len(pbpkg) != 0 {
   361  		pbpkg += ":"
   362  	}
   363  	out = fmt.Sprintf("--%s_out=%s%s", language, pbpkg, outputdir)
   364  	return out
   365  }
   366  
   367  func genPbpkg(pb2ImportPath map[string]string) string {
   368  	var pbpkg string
   369  
   370  	if len(pb2ImportPath) != 0 {
   371  		for k, v := range pb2ImportPath {
   372  
   373  			// 1. The official Google library should be left to protoc and protoc-gen-go to handle.
   374  			// 2. For other imported pb files, if they have the same validGoPkg as the protofile,
   375  			// then the package parsed by protoreflect/jhump for the pb file is empty.
   376  			// To solve the circular dependency problem here!
   377  			if strings.HasPrefix(k, "google/protobuf") || len(v) == 0 {
   378  				continue
   379  			}
   380  			//BUG: protoc-gen-go, https://google.golang.org/protobuf/issues/1151
   381  			//if v == protofileValidGoPkg {
   382  			//	v = "."
   383  			//}
   384  			//if v == protofileValidGoPkg {
   385  			//	continue
   386  			//}
   387  			//pbpkg += ",M" + k + "=" + lang.PBValidGoPackage(v)
   388  			pbpkg += ",M" + k + "=" + v
   389  		}
   390  	}
   391  	return pbpkg
   392  }
   393  
   394  func fixProtocOut(secvOut, protocOut, lang string) string {
   395  	new := fmt.Sprintf("--%s_out=lang=%s", secvOut, lang)
   396  
   397  	vals := strings.SplitN(protocOut, "=", 2)
   398  	params := vals[1]
   399  
   400  	switch lang {
   401  	case "go":
   402  		return new + "," + params
   403  	default:
   404  		return protocOut
   405  	}
   406  }
   407  
   408  func makeProtoPath(protodirs []string, must string, options options) ([]string, error) {
   409  	protodirs = append(protodirs, must)
   410  	protodirs = fs.UniqFilePath(protodirs)
   411  
   412  	args := []string{}
   413  
   414  	// BUG protoc/protoc-gen-go
   415  	// see: https://github.com/golang/protobuf/issues/1252#issuecomment-741626261
   416  	sort.Strings(protodirs)
   417  
   418  	wd, _ := os.Getwd()
   419  	for pos, each := range protodirs {
   420  		if wd == each {
   421  			var newProtodirs []string
   422  			newProtodirs = append(newProtodirs, protodirs[0:pos]...)
   423  			newProtodirs = append(newProtodirs, protodirs[pos+1:]...)
   424  			newProtodirs = append(newProtodirs, wd)
   425  			protodirs = newProtodirs
   426  			break
   427  		}
   428  	}
   429  
   430  	return genProtoPathArgs(protodirs, args)
   431  }
   432  
   433  func genProtoPathArgs(protodirs []string, args []string) ([]string, error) {
   434  	//for _, protodir := range protodirs {
   435  	for i := len(protodirs) - 1; i >= 0; i-- {
   436  		protodir := protodirs[i]
   437  		protodir, err := filepath.Abs(protodir)
   438  		if err != nil {
   439  			continue
   440  		}
   441  
   442  		// filter out non-existing directories.
   443  		fin, err := os.Lstat(protodir)
   444  		if err != nil || !fin.IsDir() {
   445  			continue
   446  		}
   447  
   448  		args = append(args, fmt.Sprintf("--proto_path=%s", protodir))
   449  	}
   450  	return args, nil
   451  }