Relationship

server-ep-server-pools.svg
图 1:Endpoint 核心数据结构关系

在上图中,EndpointServerPools 是类型 []PoolEndpoints,PoolEndpoints 中包含一个 Endpoint 数组。SetCount 表示这个 PoolEndpoints 中有几组 Endpoint,DrivesPerSet 表示一个分组中有几个终端节点。

Create Endpoints

Arguments

创建 EndpointServerPools 的代码如下所示,其中,serverCmdArgs(ctx) 获取 minio 启动命令的参数信息,接下来,进入参数处理方法,看看都做了什么。

  1. globalEndpoints, setupType, err = createServerEndpoints(globalMinioAddr, serverCmdArgs(ctx)...)

可以看到,minio 会从环境变量中检查 MINIO_ARGS 参数,如果没有配置,则读取 MINIO_ENDPOINTS 参数,如果仍然没有,直接读取命令行参数,这就是 minio 获取配置的顺序。这里需要注意,环境变量的管理并不是简单读取系统环境变量这么简单,详细请参阅环境变量

  1. // EnvArgs = "MINIO_ARGS"
  2. // EnvEndpoints = "MINIO_ENDPOINTS"
  3. func serverCmdArgs(ctx *cli.Context) []string {
  4. v := env.Get(config.EnvArgs, "")
  5. if v == "" {
  6. // Fall back to older ENV MINIO_ENDPOINTS
  7. v = env.Get(config.EnvEndpoints, "")
  8. }
  9. if v == "" {
  10. if !ctx.Args().Present() || ctx.Args().First() == "help" {
  11. cli.ShowCommandHelpAndExit(ctx, ctx.Command.Name, 1)
  12. }
  13. return ctx.Args()
  14. }
  15. return strings.Fields(v)
  16. }

Custom Set Drive Count

可通过环境变量 MINIO_ERASURE_SET_DRIVE_COUNT 来自定义 DrivePerSet 值,注意这个值最好设置为偶数。

  1. var customSetDriveCount uint64
  2. if v := env.Get(EnvErasureSetDriveCount, ""); v != "" {
  3. driveCount, err := strconv.Atoi(v)
  4. if err != nil {
  5. return nil, config.ErrInvalidErasureSetSize(err)
  6. }
  7. customSetDriveCount = uint64(driveCount)
  8. }

Parse Endpoint Set

  1. func parseEndpointSet(customSetDriveCount uint64, args ...string) (ep endpointSet, err error) {
  2. var argPatterns = make([]ellipses.ArgPattern, len(args))
  3. for i, arg := range args {
  4. patterns, perr := ellipses.FindEllipsesPatterns(arg)
  5. if perr != nil {
  6. return endpointSet{}, config.ErrInvalidErasureEndpoints(nil).Msg(perr.Error())
  7. }
  8. argPatterns[i] = patterns
  9. }
  10. ep.setIndexes, err = getSetIndexes(args, getTotalSizes(argPatterns), customSetDriveCount, argPatterns)
  11. if err != nil {
  12. return endpointSet{}, config.ErrInvalidErasureEndpoints(nil).Msg(err.Error())
  13. }
  14. ep.argPatterns = argPatterns
  15. return ep, nil
  16. }

L2 - L9:将参数进行解析,获取各自需要匹配的省略号模式;
L11: 根据模式,获取各个 Endpoint 分组信息;
L16: 保留省略号模式信息;

L11 中的 getTotalSizes 如下所示,遍历每个模式,计算每个模式展开后的实际条目数量,注意 L6 的乘法运算,展开模式时,如果有多个省略号模式,如:xxx{1…64}/yyy{1…4} 会扩展为 xxx1/yyy1 这样的项目,因此时乘法关系

  1. func getTotalSizes(argPatterns []ellipses.ArgPattern) []uint64 {
  2. var totalSizes []uint64
  3. for _, argPattern := range argPatterns {
  4. var totalSize uint64 = 1
  5. for _, p := range argPattern {
  6. totalSize = totalSize * uint64(len(p.Seq))
  7. }
  8. totalSizes = append(totalSizes, totalSize)
  9. }
  10. return totalSizes
  11. }

endpointSet 定义如下

  1. type endpointSet struct {
  2. argPatterns []ellipses.ArgPattern
  3. endpoints []string // Endpoints saved from previous GetEndpoints().
  4. setIndexes [][]uint64 // All the sets.
  5. }

Get Set Index

getSetIndexes 方法较为复杂,函数声明如下所示,需要注意的是 args 中又可能会含有包含省略号的配置项,也有可能只有一个参数,后续无特殊说明,全部都针对多参数项且带省略号配置来分析。在上面章节的分析中,我们知道 args 为输入参数,totalSize 为对应索引的 args[index] 展开后项目的条数,其他参数都有覆盖,不再说明。

  1. func getSetIndexes(
  2. args []string,
  3. totalSizes []uint64,
  4. customSetDriveCount uint64,
  5. argPatterns []ellipses.ArgPattern) (setIndexes [][]uint64, err error)

分配一个二维切片,长度与 totalSize 相同,并遍历 totalSize,筛查是否存在不满足条件的分组,如果存在则退出执行

  1. setIndexes = make([][]uint64, len(totalSizes))
  2. for _, totalSize := range totalSizes {
  3. // Check if totalSize has minimum range upto setSize
  4. if totalSize < setSizes[0] || totalSize < customSetDriveCount {
  5. msg := fmt.Sprintf("Incorrect number of endpoints provided %s", args)
  6. return nil, config.ErrInvalidNumberOfErasureEndpoints(nil).Msg(msg)
  7. }
  8. }

setSizes 定义如下,因此,一个分组长度至少为 4

  1. var setSizes = []uint64{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}

然后找到 totalSize 中全部长度的最大公约数,注意 GCD 计算方法在 L4 - L9

  1. commonSize := getDivisibleSize(totalSizes)
  2. func getDivisibleSize(totalSizes []uint64) (result uint64) {
  3. gcd := func(x, y uint64) uint64 {
  4. for y != 0 {
  5. x, y = y, x%y
  6. }
  7. return x
  8. }
  9. result = totalSizes[0]
  10. for i := 1; i < len(totalSizes); i++ {
  11. result = gcd(result, totalSizes[i])
  12. }
  13. return result
  14. }

根据计算的 commonSize,在 setSizes 中筛选能够整除 commonSize 的数,以数组形式返回

  1. possibleSetCounts := func(setSize uint64) (ss []uint64) {
  2. for _, s := range setSizes {
  3. if setSize%s == 0 {
  4. ss = append(ss, s)
  5. }
  6. }
  7. return ss
  8. }
  9. setCounts := possibleSetCounts(commonSize)
  10. if len(setCounts) == 0 {
  11. msg := fmt.Sprintf("Incorrect number of endpoints provided %s, number of disks %d is not divisible by any supported erasure set sizes %d", args, commonSize, setSizes)
  12. return nil, config.ErrInvalidNumberOfErasureEndpoints(nil).Msg(msg)
  13. }

接下来计算 setSize,如果定义了 customSetDriveCount,那么从刚返回的 setCounts 中找到这个数字,如果找不到,返回失败;

  1. var setSize uint64
  2. // Custom set drive count allows to override automatic distribution.
  3. // only meant if you want to further optimize drive distribution.
  4. if customSetDriveCount > 0 {
  5. msg := fmt.Sprintf("Invalid set drive count. Acceptable values for %d number drives are %d", commonSize, setCounts)
  6. var found bool
  7. for _, ss := range setCounts {
  8. if ss == customSetDriveCount {
  9. found = true
  10. }
  11. }
  12. if !found {
  13. return nil, config.ErrInvalidErasureSetSize(nil).Msg(msg)
  14. }
  15. // No automatic symmetry calculation expected, user is on their own
  16. setSize = customSetDriveCount
  17. globalCustomErasureDriveCount = true
  18. } else {
  19. // Returns possible set counts with symmetry.
  20. setCounts = possibleSetCountsWithSymmetry(setCounts, argPatterns)
  21. if len(setCounts) == 0 {
  22. msg := fmt.Sprintf("No symmetric distribution detected with input endpoints provided %s, disks %d cannot be spread symmetrically by any supported erasure set sizes %d", args, commonSize, setSizes)
  23. return nil, config.ErrInvalidNumberOfErasureEndpoints(nil).Msg(msg)
  24. }
  25. // Final set size with all the symmetry accounted for.
  26. setSize = commonSetDriveCount(commonSize, setCounts)
  27. }

如果没有定义 customSetDriveCount,则重新计算 setCounts。遍历原始 setCounts,找到能被全部模式展开后数量整除的数字,保存下来。注意 L5 - L13,如果一个子模式的全部展开项数量能整除,就说明这个模式能被该数字整除(一个模式展开项总数必然是子项目展开项总数的倍数)。

  1. func possibleSetCountsWithSymmetry(setCounts []uint64, argPatterns []ellipses.ArgPattern) []uint64 {
  2. var newSetCounts = make(map[uint64]struct{})
  3. for _, ss := range setCounts {
  4. var symmetry bool
  5. for _, argPattern := range argPatterns {
  6. for _, p := range argPattern {
  7. if uint64(len(p.Seq)) > ss {
  8. symmetry = uint64(len(p.Seq))%ss == 0
  9. } else {
  10. symmetry = ss%uint64(len(p.Seq)) == 0
  11. }
  12. }
  13. }
  14. // With no arg patterns, it is expected that user knows
  15. // the right symmetry, so either ellipses patterns are
  16. // provided (recommended) or no ellipses patterns.
  17. if _, ok := newSetCounts[ss]; !ok && (symmetry || argPatterns == nil) {
  18. newSetCounts[ss] = struct{}{}
  19. }
  20. }
  21. setCounts = []uint64{}
  22. for setCount := range newSetCounts {
  23. setCounts = append(setCounts, setCount)
  24. }
  25. // Not necessarily needed but it ensures to the readers
  26. // eyes that we prefer a sorted setCount slice for the
  27. // subsequent function to figure out the right common
  28. // divisor, it avoids loops.
  29. sort.Slice(setCounts, func(i, j int) bool {
  30. return setCounts[i] < setCounts[j]
  31. })
  32. return setCounts
  33. }

根据 commonSize,及新计算的 setCounts 重新计算 setSize。setSize 选择的是 setCounts 中最大的能整除 commonSize 的数。

  1. setSize = commonSetDriveCount(commonSize, setCounts)
  2. func commonSetDriveCount(divisibleSize uint64, setCounts []uint64) (setSize uint64) {
  3. // prefers setCounts to be sorted for optimal behavior.
  4. if divisibleSize < setCounts[len(setCounts)-1] {
  5. return divisibleSize
  6. }
  7. // Figure out largest value of total_drives_in_erasure_set which results
  8. // in least number of total_drives/total_drives_erasure_set ratio.
  9. prevD := divisibleSize / setCounts[0]
  10. for _, cnt := range setCounts {
  11. if divisibleSize%cnt == 0 {
  12. d := divisibleSize / cnt
  13. if d <= prevD {
  14. prevD = d
  15. setSize = cnt
  16. }
  17. }
  18. }
  19. return setSize
  20. }

最后,根据每个参数展开项长度,分别除以 setSize 的到分组数量,再填充 setSize。

  1. for i := range totalSizes {
  2. for j := uint64(0); j < totalSizes[i]/setSize; j++ {
  3. setIndexes[i] = append(setIndexes[i], setSize)
  4. }
  5. }

因此,最后返回的 setIndexes 中全部元素值都是相同的,如

  1. [
  2. [4, 4],
  3. [4, 4, 4, 4],
  4. [4, 4]
  5. ]

Get Endpoints Set

endpointSet 中,保存了原始参数,每个分组长度。Get 方法获取具体的分组方式,getEndpoints 将包含省略号的参数展开,获取全部的 Endpoint 名称,然后根据 setIndexes 进行划分。

  1. func (s endpointSet) Get() (sets [][]string) {
  2. var k = uint64(0)
  3. endpoints := s.getEndpoints()
  4. for i := range s.setIndexes {
  5. for j := range s.setIndexes[i] {
  6. sets = append(sets, endpoints[k:s.setIndexes[i][j]+k])
  7. k = s.setIndexes[i][j] + k
  8. }
  9. }
  10. return sets
  11. }

Create Server Pools

经过上面的讲解,继续看如何创建 EndpointServerPools,可以发现,整个 Arguments 部分都在下面代码的 L2 种执行。因为 GetAllSets 获取的是最终的分组,且分组长度均为相同值,那么 L12 - L13 行的计算也就不难理解了。

  1. for _, arg := range args {
  2. setArgs, err := GetAllSets(arg)
  3. if err != nil {
  4. return nil, -1, err
  5. }
  6. endpointList, gotSetupType, err := CreateEndpoints(serverAddr, foundPrevLocal, setArgs...)
  7. if err != nil {
  8. return nil, -1, err
  9. }
  10. if err = endpointServerPools.Add(PoolEndpoints{
  11. SetCount: len(setArgs),
  12. DrivesPerSet: len(setArgs[0]),
  13. Endpoints: endpointList,
  14. }); err != nil {
  15. return nil, -1, err
  16. }
  17. foundPrevLocal = endpointList.atleastOneEndpointLocal()
  18. if setupType == UnknownSetupType {
  19. setupType = gotSetupType
  20. }
  21. if setupType == ErasureSetupType && gotSetupType == DistErasureSetupType {
  22. setupType = DistErasureSetupType
  23. }
  24. }

NewEndpoints

根据传入的节点地址,创建全部节点,注意全部节点类型必须一致,否则会报错。String Set 按照字符串集合来理解,可确认内部没有重复元素。

  1. func NewEndpoints(args ...string) (endpoints Endpoints, err error) {
  2. var endpointType EndpointType
  3. var scheme string
  4. uniqueArgs := set.NewStringSet()
  5. // Loop through args and adds to endpoint list.
  6. for i, arg := range args {
  7. endpoint, err := NewEndpoint(arg)
  8. if err != nil {
  9. return nil, fmt.Errorf("'%s': %s", arg, err.Error())
  10. }
  11. // All endpoints have to be same type and scheme if applicable.
  12. if i == 0 {
  13. endpointType = endpoint.Type()
  14. scheme = endpoint.Scheme
  15. } else if endpoint.Type() != endpointType {
  16. return nil, fmt.Errorf("mixed style endpoints are not supported")
  17. } else if endpoint.Scheme != scheme {
  18. return nil, fmt.Errorf("mixed scheme is not supported")
  19. }
  20. arg = endpoint.String()
  21. if uniqueArgs.Contains(arg) {
  22. return nil, fmt.Errorf("duplicate endpoints found")
  23. }
  24. uniqueArgs.Add(arg)
  25. endpoints = append(endpoints, endpoint)
  26. }
  27. return endpoints, nil
  28. }

不难看出,核心方法是 NewEndpoint,代码如下

  1. func NewEndpoint(arg string) (ep Endpoint, e error) {
  2. // isEmptyPath - check whether given path is not empty.
  3. isEmptyPath := func(path string) bool {
  4. return path == "" || path == SlashSeparator || path == `\`
  5. }
  6. if isEmptyPath(arg) {
  7. return ep, fmt.Errorf("empty or root endpoint is not supported")
  8. }
  9. var isLocal bool
  10. var host string
  11. u, err := url.Parse(arg)
  12. if err == nil && u.Host != "" {
  13. // URL style of endpoint.
  14. // Valid URL style endpoint is
  15. // - Scheme field must contain "http" or "https"
  16. // - All field should be empty except Host and Path.
  17. if !((u.Scheme == "http" || u.Scheme == "https") &&
  18. u.User == nil && u.Opaque == "" && !u.ForceQuery && u.RawQuery == "" && u.Fragment == "") {
  19. return ep, fmt.Errorf("invalid URL endpoint format")
  20. }
  21. var port string
  22. host, port, err = net.SplitHostPort(u.Host)
  23. if err != nil {
  24. if !strings.Contains(err.Error(), "missing port in address") {
  25. return ep, fmt.Errorf("invalid URL endpoint format: %w", err)
  26. }
  27. host = u.Host
  28. } else {
  29. var p int
  30. p, err = strconv.Atoi(port)
  31. if err != nil {
  32. return ep, fmt.Errorf("invalid URL endpoint format: invalid port number")
  33. } else if p < 1 || p > 65535 {
  34. return ep, fmt.Errorf("invalid URL endpoint format: port number must be between 1 to 65535")
  35. }
  36. }
  37. if i := strings.Index(host, "%"); i > -1 {
  38. host = host[:i]
  39. }
  40. if host == "" {
  41. return ep, fmt.Errorf("invalid URL endpoint format: empty host name")
  42. }
  43. // As this is path in the URL, we should use path package, not filepath package.
  44. // On MS Windows, filepath.Clean() converts into Windows path style ie `/foo` becomes `\foo`
  45. u.Path = path.Clean(u.Path)
  46. if isEmptyPath(u.Path) {
  47. return ep, fmt.Errorf("empty or root path is not supported in URL endpoint")
  48. }
  49. // On windows having a preceding SlashSeparator will cause problems, if the
  50. // command line already has C:/<export-folder/ in it. Final resulting
  51. // path on windows might become C:/C:/ this will cause problems
  52. // of starting minio server properly in distributed mode on windows.
  53. // As a special case make sure to trim the separator.
  54. // NOTE: It is also perfectly fine for windows users to have a path
  55. // without C:/ since at that point we treat it as relative path
  56. // and obtain the full filesystem path as well. Providing C:/
  57. // style is necessary to provide paths other than C:/,
  58. // such as F:/, D:/ etc.
  59. //
  60. // Another additional benefit here is that this style also
  61. // supports providing \\host\share support as well.
  62. if runtime.GOOS == globalWindowsOSName {
  63. if filepath.VolumeName(u.Path[1:]) != "" {
  64. u.Path = u.Path[1:]
  65. }
  66. }
  67. } else {
  68. // Only check if the arg is an ip address and ask for scheme since its absent.
  69. // localhost, example.com, any FQDN cannot be disambiguated from a regular file path such as
  70. // /mnt/export1. So we go ahead and start the minio server in FS modes in these cases.
  71. if isHostIP(arg) {
  72. return ep, fmt.Errorf("invalid URL endpoint format: missing scheme http or https")
  73. }
  74. absArg, err := filepath.Abs(arg)
  75. if err != nil {
  76. return Endpoint{}, fmt.Errorf("absolute path failed %s", err)
  77. }
  78. u = &url.URL{Path: path.Clean(absArg)}
  79. isLocal = true
  80. }
  81. return Endpoint{
  82. URL: u,
  83. IsLocal: isLocal,
  84. }, nil
  85. }

L14 - L75 处理 URL 式的参数;L77 - L88 处理文件路径式的参数。需要注意,仅当参数为文件路径时,isLocal 为 true。

Endpoint Type

Endpoint 只有两种类型,PathEndpointType 与 URLEndpointType,分别对应本地文件系统类型和网络类型的节点。

  1. func (endpoint Endpoint) Type() EndpointType {
  2. if endpoint.Host == "" {
  3. return PathEndpointType
  4. }
  5. return URLEndpointType
  6. }

创建完全部的节点后,判断节点类型,如果均为 Path 型,完成退出。

  1. if endpoints[0].Type() == PathEndpointType {
  2. setupType = ErasureSetupType
  3. return endpoints, setupType, nil
  4. }

如果全部节点均为 URL 类型,继续处理,首先解析全部节点或至少找到一个本地节点,UpdateIsLocal 退出条件为找到至少一个本地节点或全部节点都解析完毕。然后继续统计全部节点的 URL 路径,本地端口、本地路径等,因为使用的都是 Set 类型,因此这几个变量中都不会包含重复元素。

  1. if err = endpoints.UpdateIsLocal(foundLocal); err != nil {
  2. return endpoints, setupType, config.ErrInvalidErasureEndpoints(nil).Msg(err.Error())
  3. }
  4. // Here all endpoints are URL style.
  5. endpointPathSet := set.NewStringSet()
  6. localEndpointCount := 0
  7. localServerHostSet := set.NewStringSet()
  8. localPortSet := set.NewStringSet()
  9. for _, endpoint := range endpoints {
  10. endpointPathSet.Add(endpoint.Path)
  11. if endpoint.IsLocal {
  12. localServerHostSet.Add(endpoint.Hostname())
  13. var port string
  14. _, port, err = net.SplitHostPort(endpoint.Host)
  15. if err != nil {
  16. port = serverAddrPort
  17. }
  18. localPortSet.Add(port)
  19. localEndpointCount++
  20. }
  21. }

确保不存在监听在同一主机 IP 但是不同端口的节点存在

  1. {
  2. pathIPMap := make(map[string]set.StringSet)
  3. for _, endpoint := range endpoints {
  4. host := endpoint.Hostname()
  5. hostIPSet, _ := getHostIP(host)
  6. if IPSet, ok := pathIPMap[endpoint.Path]; ok {
  7. if !IPSet.Intersection(hostIPSet).IsEmpty() {
  8. return endpoints, setupType,
  9. config.ErrInvalidErasureEndpoints(nil).Msg(fmt.Sprintf("path '%s' can not be served by different port on same address", endpoint.Path))
  10. }
  11. pathIPMap[endpoint.Path] = IPSet.Union(hostIPSet)
  12. } else {
  13. pathIPMap[endpoint.Path] = hostIPSet
  14. }
  15. }
  16. }

确保一个本地路径上最多只有一个节点

  1. {
  2. localPathSet := set.CreateStringSet()
  3. for _, endpoint := range endpoints {
  4. if !endpoint.IsLocal {
  5. continue
  6. }
  7. if localPathSet.Contains(endpoint.Path) {
  8. return endpoints, setupType,
  9. config.ErrInvalidErasureEndpoints(nil).Msg(fmt.Sprintf("path '%s' cannot be served by different address on same server", endpoint.Path))
  10. }
  11. localPathSet.Add(endpoint.Path)
  12. }
  13. }

如果全部节点都是本地节点,且都监听在同一端口,那么设置类型为 ErasureSetupType。否则,即使全部均为本地节点,仍然是 DistErasureSetupType 类型。

  1. if len(endpoints) == localEndpointCount {
  2. // If all endpoints have same port number, Just treat it as local erasure setup
  3. // using URL style endpoints.
  4. if len(localPortSet) == 1 {
  5. if len(localServerHostSet) > 1 {
  6. return endpoints, setupType,
  7. config.ErrInvalidErasureEndpoints(nil).Msg("all local endpoints should not have different hostnames/ips")
  8. }
  9. return endpoints, ErasureSetupType, nil
  10. }
  11. // Even though all endpoints are local, but those endpoints use different ports.
  12. // This means it is DistErasure setup.
  13. }

检查是否存在没有监听端口配置的节点存在,如果存在,补全;如果是本地节点切实际监听端口和全局端口不同,设置 IsLocal 为 false。

  1. for i := range endpoints {
  2. _, port, err := net.SplitHostPort(endpoints[i].Host)
  3. if err != nil {
  4. endpoints[i].Host = net.JoinHostPort(endpoints[i].Host, serverAddrPort)
  5. } else if endpoints[i].IsLocal && serverAddrPort != port {
  6. // If endpoint is local, but port is different than serverAddrPort, then make it as remote.
  7. endpoints[i].IsLocal = false
  8. }
  9. }
  10. uniqueArgs := set.NewStringSet()
  11. for _, endpoint := range endpoints {
  12. uniqueArgs.Add(endpoint.Host)
  13. }

检查是否配置了公共 IP 地址,如果没有配置公共 IP,更新主机为 IP:Port 形式。

  1. publicIPs := env.Get(config.EnvPublicIPs, "")
  2. if len(publicIPs) == 0 {
  3. updateDomainIPs(uniqueArgs)
  4. }
  5. setupType = DistErasureSetupType

updateDomainIPs 代码如下

  1. func updateDomainIPs(endPoints set.StringSet) {
  2. ipList := set.NewStringSet()
  3. for e := range endPoints {
  4. host, port, err := net.SplitHostPort(e)
  5. if err != nil {
  6. if strings.Contains(err.Error(), "missing port in address") {
  7. host = e
  8. port = globalMinioPort
  9. } else {
  10. continue
  11. }
  12. }
  13. if net.ParseIP(host) == nil {
  14. IPs, err := getHostIP(host)
  15. if err != nil {
  16. continue
  17. }
  18. IPsWithPort := IPs.ApplyFunc(func(ip string) string {
  19. return net.JoinHostPort(ip, port)
  20. })
  21. ipList = ipList.Union(IPsWithPort)
  22. }
  23. ipList.Add(net.JoinHostPort(host, port))
  24. }
  25. globalDomainIPs = ipList.FuncMatch(func(ip string, matchString string) bool {
  26. host, _, err := net.SplitHostPort(ip)
  27. if err != nil {
  28. host = ip
  29. }
  30. return !net.ParseIP(host).IsLoopback() && host != "localhost"
  31. }, "")
  32. }