github.com/SaurabhDubey-Groww/go-cloud@v0.0.0-20221124105541-b26c29285fd8/pubsub/gcppubsub/gcppubsub.go (about) 1 // Copyright 2018 The Go Cloud Development Kit Authors 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // https://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package gcppubsub provides a pubsub implementation that uses GCP 16 // PubSub. Use OpenTopic to construct a *pubsub.Topic, and/or OpenSubscription 17 // to construct a *pubsub.Subscription. 18 // 19 // # URLs 20 // 21 // For pubsub.OpenTopic and pubsub.OpenSubscription, gcppubsub registers 22 // for the scheme "gcppubsub". 23 // The default URL opener will creating a connection using use default 24 // credentials from the environment, as described in 25 // https://cloud.google.com/docs/authentication/production. 26 // To customize the URL opener, or for more details on the URL format, 27 // see URLOpener. 28 // See https://gocloud.dev/concepts/urls/ for background information. 29 // 30 // GCP Pub/Sub emulator is supported as per https://cloud.google.com/pubsub/docs/emulator 31 // So, when environment variable 'PUBSUB_EMULATOR_HOST' is set 32 // driver connects to the specified emulator host by default. 33 // 34 // # Message Delivery Semantics 35 // 36 // GCP Pub/Sub supports at-least-once semantics; applications must 37 // call Message.Ack after processing a message, or it will be redelivered. 38 // See https://godoc.org/gocloud.dev/pubsub#hdr-At_most_once_and_At_least_once_Delivery 39 // for more background. 40 // 41 // # As 42 // 43 // gcppubsub exposes the following types for As: 44 // - Topic: *raw.PublisherClient 45 // - Subscription: *raw.SubscriberClient 46 // - Message.BeforeSend: *pb.PubsubMessage 47 // - Message.AfterSend: *string for the pb.PublishResponse.MessageIds entry corresponding to the message. 48 // - Message: *pb.PubsubMessage, *pb.ReceivedMessage 49 // - Error: *google.golang.org/grpc/status.Status 50 package gcppubsub // import "gocloud.dev/pubsub/gcppubsub" 51 52 import ( 53 "context" 54 "fmt" 55 "net/url" 56 "os" 57 "path" 58 "regexp" 59 "strconv" 60 "strings" 61 "sync" 62 "time" 63 64 raw "cloud.google.com/go/pubsub/apiv1" 65 "github.com/google/wire" 66 "gocloud.dev/gcerrors" 67 "gocloud.dev/gcp" 68 "gocloud.dev/internal/gcerr" 69 "gocloud.dev/internal/useragent" 70 "gocloud.dev/pubsub" 71 "gocloud.dev/pubsub/batcher" 72 "gocloud.dev/pubsub/driver" 73 "google.golang.org/api/option" 74 pb "google.golang.org/genproto/googleapis/pubsub/v1" 75 "google.golang.org/grpc" 76 "google.golang.org/grpc/credentials" 77 "google.golang.org/grpc/credentials/oauth" 78 "google.golang.org/grpc/status" 79 ) 80 81 var endPoint = "pubsub.googleapis.com:443" 82 83 var sendBatcherOpts = &batcher.Options{ 84 MaxBatchSize: 1000, // The PubSub service limits the number of messages in a single Publish RPC 85 MaxHandlers: 2, 86 // The PubSub service limits the size of the request body in a single Publish RPC. 87 // The limit is currently documented as "10MB (total size)" and "10MB (data field)" per message. 88 // We are enforcing 9MiB to give ourselves some headroom for message attributes since those 89 // are currently not considered when computing the byte size of a message. 90 MaxBatchByteSize: 9 * 1024 * 1024, 91 } 92 93 var defaultRecvBatcherOpts = &batcher.Options{ 94 // GCP Pub/Sub returns at most 1000 messages per RPC. 95 MaxBatchSize: 1000, 96 MaxHandlers: 10, 97 } 98 99 var ackBatcherOpts = &batcher.Options{ 100 // The PubSub service limits the size of Acknowledge/ModifyAckDeadline RPCs. 101 // (E.g., "Request payload size exceeds the limit: 524288 bytes."). 102 MaxBatchSize: 1000, 103 MaxHandlers: 2, 104 } 105 106 func init() { 107 o := new(lazyCredsOpener) 108 pubsub.DefaultURLMux().RegisterTopic(Scheme, o) 109 pubsub.DefaultURLMux().RegisterSubscription(Scheme, o) 110 } 111 112 // Set holds Wire providers for this package. 113 var Set = wire.NewSet( 114 Dial, 115 PublisherClient, 116 SubscriberClient, 117 wire.Struct(new(SubscriptionOptions)), 118 wire.Struct(new(TopicOptions)), 119 wire.Struct(new(URLOpener), "Conn", "TopicOptions", "SubscriptionOptions"), 120 ) 121 122 // lazyCredsOpener obtains Application Default Credentials on the first call 123 // to OpenTopicURL/OpenSubscriptionURL. 124 type lazyCredsOpener struct { 125 init sync.Once 126 opener *URLOpener 127 err error 128 } 129 130 func (o *lazyCredsOpener) defaultConn(ctx context.Context) (*URLOpener, error) { 131 o.init.Do(func() { 132 var conn *grpc.ClientConn 133 var err error 134 if e := os.Getenv("PUBSUB_EMULATOR_HOST"); e != "" { 135 // Connect to the GCP pubsub emulator by overriding the default endpoint 136 // if the 'PUBSUB_EMULATOR_HOST' environment variable is set. 137 // Check https://cloud.google.com/pubsub/docs/emulator for more info. 138 endPoint = e 139 conn, err = dialEmulator(ctx, e) 140 if err != nil { 141 o.err = err 142 return 143 } 144 } else { 145 creds, err := gcp.DefaultCredentials(ctx) 146 if err != nil { 147 o.err = err 148 return 149 } 150 151 conn, _, err = Dial(ctx, creds.TokenSource) 152 if err != nil { 153 o.err = err 154 return 155 } 156 } 157 o.opener = &URLOpener{Conn: conn} 158 }) 159 return o.opener, o.err 160 } 161 162 func (o *lazyCredsOpener) OpenTopicURL(ctx context.Context, u *url.URL) (*pubsub.Topic, error) { 163 opener, err := o.defaultConn(ctx) 164 if err != nil { 165 return nil, fmt.Errorf("open topic %v: failed to open default connection: %v", u, err) 166 } 167 return opener.OpenTopicURL(ctx, u) 168 } 169 170 func (o *lazyCredsOpener) OpenSubscriptionURL(ctx context.Context, u *url.URL) (*pubsub.Subscription, error) { 171 opener, err := o.defaultConn(ctx) 172 if err != nil { 173 return nil, fmt.Errorf("open subscription %v: failed to open default connection: %v", u, err) 174 } 175 return opener.OpenSubscriptionURL(ctx, u) 176 } 177 178 // Scheme is the URL scheme gcppubsub registers its URLOpeners under on pubsub.DefaultMux. 179 const Scheme = "gcppubsub" 180 181 // URLOpener opens GCP Pub/Sub URLs like "gcppubsub://projects/myproject/topics/mytopic" for 182 // topics or "gcppubsub://projects/myproject/subscriptions/mysub" for subscriptions. 183 // 184 // The shortened forms "gcppubsub://myproject/mytopic" for topics or 185 // "gcppubsub://myproject/mysub" for subscriptions are also supported. 186 // 187 // The following query parameters are supported: 188 // 189 // - max_recv_batch_size: sets SubscriptionOptions.MaxBatchSize 190 // 191 // Currently their use is limited to subscribers. 192 type URLOpener struct { 193 // Conn must be set to a non-nil ClientConn authenticated with 194 // Cloud Pub/Sub scope or equivalent. 195 Conn *grpc.ClientConn 196 197 // TopicOptions specifies the options to pass to OpenTopic. 198 TopicOptions TopicOptions 199 // SubscriptionOptions specifies the options to pass to OpenSubscription. 200 SubscriptionOptions SubscriptionOptions 201 } 202 203 // OpenTopicURL opens a pubsub.Topic based on u. 204 func (o *URLOpener) OpenTopicURL(ctx context.Context, u *url.URL) (*pubsub.Topic, error) { 205 for param := range u.Query() { 206 return nil, fmt.Errorf("open topic %v: invalid query parameter %q", u, param) 207 } 208 pc, err := PublisherClient(ctx, o.Conn) 209 if err != nil { 210 return nil, err 211 } 212 topicPath := path.Join(u.Host, u.Path) 213 if topicPathRE.MatchString(topicPath) { 214 return OpenTopicByPath(pc, topicPath, &o.TopicOptions) 215 } 216 // Shortened form? 217 topicName := strings.TrimPrefix(u.Path, "/") 218 return OpenTopic(pc, gcp.ProjectID(u.Host), topicName, &o.TopicOptions), nil 219 } 220 221 // OpenSubscriptionURL opens a pubsub.Subscription based on u. 222 func (o *URLOpener) OpenSubscriptionURL(ctx context.Context, u *url.URL) (*pubsub.Subscription, error) { 223 // Set subscription options to use defaults 224 opts := o.SubscriptionOptions 225 226 for param, value := range u.Query() { 227 switch param { 228 case "max_recv_batch_size": 229 maxBatchSize, err := queryParameterInt(value) 230 if err != nil { 231 return nil, fmt.Errorf("open subscription %v: invalid query parameter %q: %v", u, param, err) 232 } 233 234 if maxBatchSize <= 0 || maxBatchSize > 1000 { 235 return nil, fmt.Errorf("open subscription %v: invalid query parameter %q: must be between 1 and 1000", u, param) 236 } 237 238 opts.MaxBatchSize = maxBatchSize 239 default: 240 return nil, fmt.Errorf("open subscription %v: invalid query parameter %q", u, param) 241 } 242 } 243 sc, err := SubscriberClient(ctx, o.Conn) 244 if err != nil { 245 return nil, err 246 } 247 subPath := path.Join(u.Host, u.Path) 248 if subscriptionPathRE.MatchString(subPath) { 249 return OpenSubscriptionByPath(sc, subPath, &opts) 250 } 251 // Shortened form? 252 subName := strings.TrimPrefix(u.Path, "/") 253 return OpenSubscription(sc, gcp.ProjectID(u.Host), subName, &opts), nil 254 } 255 256 type topic struct { 257 path string 258 client *raw.PublisherClient 259 } 260 261 // Dial opens a gRPC connection to the GCP Pub Sub API. 262 // 263 // The second return value is a function that can be called to clean up 264 // the connection opened by Dial. 265 func Dial(ctx context.Context, ts gcp.TokenSource) (*grpc.ClientConn, func(), error) { 266 conn, err := grpc.DialContext(ctx, endPoint, 267 grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), 268 grpc.WithPerRPCCredentials(oauth.TokenSource{TokenSource: ts}), 269 // The default message size limit for gRPC is 4MB, while GCP 270 // PubSub supports messages up to 10MB. Aside from the message itself 271 // there is also other data in the gRPC response, bringing the maximum 272 // response size above 10MB. Tell gRPC to support up to 11MB. 273 // https://github.com/googleapis/google-cloud-node/issues/1991 274 grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(1024*1024*11)), 275 useragent.GRPCDialOption("pubsub"), 276 ) 277 278 if err != nil { 279 return nil, nil, err 280 } 281 return conn, func() { conn.Close() }, nil 282 } 283 284 // dialEmulator opens a gRPC connection to the GCP Pub Sub API. 285 func dialEmulator(ctx context.Context, e string) (*grpc.ClientConn, error) { 286 conn, err := grpc.DialContext(ctx, e, grpc.WithInsecure(), useragent.GRPCDialOption("pubsub")) 287 if err != nil { 288 return nil, err 289 } 290 return conn, nil 291 } 292 293 // PublisherClient returns a *raw.PublisherClient that can be used in OpenTopic. 294 func PublisherClient(ctx context.Context, conn *grpc.ClientConn) (*raw.PublisherClient, error) { 295 return raw.NewPublisherClient(ctx, option.WithGRPCConn(conn)) 296 } 297 298 // SubscriberClient returns a *raw.SubscriberClient that can be used in OpenSubscription. 299 func SubscriberClient(ctx context.Context, conn *grpc.ClientConn) (*raw.SubscriberClient, error) { 300 return raw.NewSubscriberClient(ctx, option.WithGRPCConn(conn)) 301 } 302 303 // TopicOptions will contain configuration for topics. 304 type TopicOptions struct { 305 // BatcherOptions adds constraints to the default batching done for sends. 306 BatcherOptions batcher.Options 307 } 308 309 // OpenTopic returns a *pubsub.Topic backed by an existing GCP PubSub topic 310 // in the given projectID. topicName is the last part of the full topic 311 // path, e.g., "foo" from "projects/<projectID>/topic/foo". 312 // See the package documentation for an example. 313 func OpenTopic(client *raw.PublisherClient, projectID gcp.ProjectID, topicName string, opts *TopicOptions) *pubsub.Topic { 314 topicPath := fmt.Sprintf("projects/%s/topics/%s", projectID, topicName) 315 if opts == nil { 316 opts = &TopicOptions{} 317 } 318 bo := sendBatcherOpts.NewMergedOptions(&opts.BatcherOptions) 319 return pubsub.NewTopic(openTopic(client, topicPath), bo) 320 } 321 322 var topicPathRE = regexp.MustCompile("^projects/.+/topics/.+$") 323 324 // OpenTopicByPath returns a *pubsub.Topic backed by an existing GCP PubSub 325 // topic. topicPath must be of the form "projects/<projectID>/topic/<topic>". 326 // See the package documentation for an example. 327 func OpenTopicByPath(client *raw.PublisherClient, topicPath string, opts *TopicOptions) (*pubsub.Topic, error) { 328 if !topicPathRE.MatchString(topicPath) { 329 return nil, fmt.Errorf("invalid topicPath %q; must match %v", topicPath, topicPathRE) 330 } 331 if opts == nil { 332 opts = &TopicOptions{} 333 } 334 bo := sendBatcherOpts.NewMergedOptions(&opts.BatcherOptions) 335 return pubsub.NewTopic(openTopic(client, topicPath), bo), nil 336 } 337 338 // openTopic returns the driver for OpenTopic. This function exists so the test 339 // harness can get the driver interface implementation if it needs to. 340 func openTopic(client *raw.PublisherClient, topicPath string) driver.Topic { 341 return &topic{topicPath, client} 342 } 343 344 // SendBatch implements driver.Topic.SendBatch. 345 func (t *topic) SendBatch(ctx context.Context, dms []*driver.Message) error { 346 var ms []*pb.PubsubMessage 347 for _, dm := range dms { 348 psm := &pb.PubsubMessage{Data: dm.Body, Attributes: dm.Metadata} 349 if dm.BeforeSend != nil { 350 asFunc := func(i interface{}) bool { 351 if p, ok := i.(**pb.PubsubMessage); ok { 352 *p = psm 353 return true 354 } 355 return false 356 } 357 if err := dm.BeforeSend(asFunc); err != nil { 358 return err 359 } 360 } 361 ms = append(ms, psm) 362 } 363 req := &pb.PublishRequest{Topic: t.path, Messages: ms} 364 pr, err := t.client.Publish(ctx, req) 365 if err != nil { 366 return err 367 } 368 if len(pr.MessageIds) == len(dms) { 369 for n, dm := range dms { 370 if dm.AfterSend != nil { 371 asFunc := func(i interface{}) bool { 372 if p, ok := i.(*string); ok { 373 *p = pr.MessageIds[n] 374 return true 375 } 376 return false 377 } 378 if err := dm.AfterSend(asFunc); err != nil { 379 return err 380 } 381 } 382 } 383 } 384 return nil 385 } 386 387 // IsRetryable implements driver.Topic.IsRetryable. 388 func (t *topic) IsRetryable(error) bool { 389 // The client handles retries. 390 return false 391 } 392 393 // As implements driver.Topic.As. 394 func (t *topic) As(i interface{}) bool { 395 c, ok := i.(**raw.PublisherClient) 396 if !ok { 397 return false 398 } 399 *c = t.client 400 return true 401 } 402 403 // ErrorAs implements driver.Topic.ErrorAs 404 func (*topic) ErrorAs(err error, i interface{}) bool { 405 return errorAs(err, i) 406 } 407 408 func errorAs(err error, i interface{}) bool { 409 s, ok := status.FromError(err) 410 if !ok { 411 return false 412 } 413 p, ok := i.(**status.Status) 414 if !ok { 415 return false 416 } 417 *p = s 418 return true 419 } 420 421 func (*topic) ErrorCode(err error) gcerrors.ErrorCode { 422 return gcerr.GRPCCode(err) 423 } 424 425 // Close implements driver.Topic.Close. 426 func (*topic) Close() error { return nil } 427 428 type subscription struct { 429 client *raw.SubscriberClient 430 path string 431 options *SubscriptionOptions 432 } 433 434 // SubscriptionOptions will contain configuration for subscriptions. 435 type SubscriptionOptions struct { 436 // MaxBatchSize caps the maximum batch size used when retrieving messages. It defaults to 1000. 437 MaxBatchSize int 438 439 // ReceiveBatcherOptions adds constraints to the default batching done for receives. 440 ReceiveBatcherOptions batcher.Options 441 442 // AckBatcherOptions adds constraints to the default batching done for acks. 443 AckBatcherOptions batcher.Options 444 } 445 446 // OpenSubscription returns a *pubsub.Subscription backed by an existing GCP 447 // PubSub subscription subscriptionName in the given projectID. See the package 448 // documentation for an example. 449 func OpenSubscription(client *raw.SubscriberClient, projectID gcp.ProjectID, subscriptionName string, opts *SubscriptionOptions) *pubsub.Subscription { 450 path := fmt.Sprintf("projects/%s/subscriptions/%s", projectID, subscriptionName) 451 452 dsub := openSubscription(client, path, opts) 453 recvOpts := *defaultRecvBatcherOpts 454 recvOpts.MaxBatchSize = dsub.options.MaxBatchSize 455 rbo := recvOpts.NewMergedOptions(&dsub.options.ReceiveBatcherOptions) 456 abo := ackBatcherOpts.NewMergedOptions(&dsub.options.AckBatcherOptions) 457 return pubsub.NewSubscription(dsub, rbo, abo) 458 } 459 460 var subscriptionPathRE = regexp.MustCompile("^projects/.+/subscriptions/.+$") 461 462 // OpenSubscriptionByPath returns a *pubsub.Subscription backed by an existing 463 // GCP PubSub subscription. subscriptionPath must be of the form 464 // "projects/<projectID>/subscriptions/<subscription>". 465 // See the package documentation for an example. 466 func OpenSubscriptionByPath(client *raw.SubscriberClient, subscriptionPath string, opts *SubscriptionOptions) (*pubsub.Subscription, error) { 467 if !subscriptionPathRE.MatchString(subscriptionPath) { 468 return nil, fmt.Errorf("invalid subscriptionPath %q; must match %v", subscriptionPath, subscriptionPathRE) 469 } 470 471 dsub := openSubscription(client, subscriptionPath, opts) 472 recvOpts := *defaultRecvBatcherOpts 473 recvOpts.MaxBatchSize = dsub.options.MaxBatchSize 474 rbo := recvOpts.NewMergedOptions(&dsub.options.ReceiveBatcherOptions) 475 abo := ackBatcherOpts.NewMergedOptions(&dsub.options.AckBatcherOptions) 476 return pubsub.NewSubscription(dsub, rbo, abo), nil 477 } 478 479 // openSubscription returns a driver.Subscription. 480 func openSubscription(client *raw.SubscriberClient, subscriptionPath string, opts *SubscriptionOptions) *subscription { 481 if opts == nil { 482 opts = &SubscriptionOptions{} 483 } 484 if opts.MaxBatchSize == 0 { 485 opts.MaxBatchSize = defaultRecvBatcherOpts.MaxBatchSize 486 } 487 return &subscription{client, subscriptionPath, opts} 488 } 489 490 // ReceiveBatch implements driver.Subscription.ReceiveBatch. 491 func (s *subscription) ReceiveBatch(ctx context.Context, maxMessages int) ([]*driver.Message, error) { 492 // Whether to ask Pull to return immediately, or wait for some messages to 493 // arrive. If we're making multiple RPCs, we don't want any of them to wait; 494 // we might have gotten messages from one of the other RPCs. 495 // maxMessages will only be high enough to set this to true in high-throughput 496 // situations, so the likelihood of getting 0 messages is small anyway. 497 returnImmediately := maxMessages == s.options.MaxBatchSize 498 499 req := &pb.PullRequest{ 500 Subscription: s.path, 501 ReturnImmediately: returnImmediately, 502 MaxMessages: int32(maxMessages), 503 } 504 resp, err := s.client.Pull(ctx, req) 505 if err != nil { 506 return nil, err 507 } 508 if len(resp.ReceivedMessages) == 0 { 509 // If we did happen to get 0 messages, and we didn't ask the server to wait 510 // for messages, sleep a bit to avoid spinning. 511 if returnImmediately { 512 time.Sleep(100 * time.Millisecond) 513 } 514 return nil, nil 515 } 516 517 ms := make([]*driver.Message, 0, len(resp.ReceivedMessages)) 518 for _, rm := range resp.ReceivedMessages { 519 rm := rm 520 rmm := rm.Message 521 m := &driver.Message{ 522 LoggableID: rmm.MessageId, 523 Body: rmm.Data, 524 Metadata: rmm.Attributes, 525 AckID: rm.AckId, 526 AsFunc: messageAsFunc(rmm, rm), 527 } 528 ms = append(ms, m) 529 } 530 return ms, nil 531 } 532 533 func messageAsFunc(pm *pb.PubsubMessage, rm *pb.ReceivedMessage) func(interface{}) bool { 534 return func(i interface{}) bool { 535 ip, ok := i.(**pb.PubsubMessage) 536 if ok { 537 *ip = pm 538 return true 539 } 540 rp, ok := i.(**pb.ReceivedMessage) 541 if ok { 542 *rp = rm 543 return true 544 } 545 return false 546 } 547 } 548 549 // SendAcks implements driver.Subscription.SendAcks. 550 func (s *subscription) SendAcks(ctx context.Context, ids []driver.AckID) error { 551 ids2 := make([]string, 0, len(ids)) 552 for _, id := range ids { 553 ids2 = append(ids2, id.(string)) 554 } 555 return s.client.Acknowledge(ctx, &pb.AcknowledgeRequest{Subscription: s.path, AckIds: ids2}) 556 } 557 558 // CanNack implements driver.CanNack. 559 func (s *subscription) CanNack() bool { return true } 560 561 // SendNacks implements driver.Subscription.SendNacks. 562 func (s *subscription) SendNacks(ctx context.Context, ids []driver.AckID) error { 563 ids2 := make([]string, 0, len(ids)) 564 for _, id := range ids { 565 ids2 = append(ids2, id.(string)) 566 } 567 return s.client.ModifyAckDeadline(ctx, &pb.ModifyAckDeadlineRequest{ 568 Subscription: s.path, 569 AckIds: ids2, 570 AckDeadlineSeconds: 0, 571 }) 572 } 573 574 // IsRetryable implements driver.Subscription.IsRetryable. 575 func (s *subscription) IsRetryable(err error) bool { 576 // The client mostly handles retries, but does not 577 // include DeadlineExceeded for some reason. 578 if s.ErrorCode(err) == gcerrors.DeadlineExceeded { 579 return true 580 } 581 return false 582 } 583 584 // As implements driver.Subscription.As. 585 func (s *subscription) As(i interface{}) bool { 586 c, ok := i.(**raw.SubscriberClient) 587 if !ok { 588 return false 589 } 590 *c = s.client 591 return true 592 } 593 594 // ErrorAs implements driver.Subscription.ErrorAs 595 func (*subscription) ErrorAs(err error, i interface{}) bool { 596 return errorAs(err, i) 597 } 598 599 func (*subscription) ErrorCode(err error) gcerrors.ErrorCode { 600 return gcerr.GRPCCode(err) 601 } 602 603 // Close implements driver.Subscription.Close. 604 func (*subscription) Close() error { return nil } 605 606 func queryParameterInt(value []string) (int, error) { 607 if len(value) > 1 { 608 return 0, fmt.Errorf("expected only one parameter value, got: %v", len(value)) 609 } 610 611 return strconv.Atoi(value[0]) 612 }