mirror of
https://github.com/golang/go
synced 2024-11-23 10:00:03 -07:00
database/sql: add examples for opening and testing a DB pool
Show two larger application examples. One example that could be used in a CLI, the other in a long running service. These demonstarates different strategies for handling DB.Ping errors in context. Fixes #23738 Change-Id: Id01213caf1f47917239a7506b01d30e37db74d31 Reviewed-on: https://go-review.googlesource.com/c/101216 Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
This commit is contained in:
parent
8f4bc468a7
commit
8d335084cc
86
src/database/sql/example_cli_test.go
Normal file
86
src/database/sql/example_cli_test.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package sql_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pool *sql.DB // Database connection pool.
|
||||||
|
|
||||||
|
func Example_openDBCLI() {
|
||||||
|
id := flag.Int64("id", 0, "person ID to find")
|
||||||
|
dsn := flag.String("dsn", os.Getenv("DSN"), "connection data source name")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if len(*dsn) == 0 {
|
||||||
|
log.Fatal("missing dsn flag")
|
||||||
|
}
|
||||||
|
if *id == 0 {
|
||||||
|
log.Fatal("missing person ID")
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Opening a driver typically will not attempt to connect to the database.
|
||||||
|
pool, err = sql.Open("driver-name", *dsn)
|
||||||
|
if err != nil {
|
||||||
|
// This will not be a connection error, but a DSN parse error or
|
||||||
|
// another initialization error.
|
||||||
|
log.Fatal("unable to use data source name", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
pool.SetConnMaxLifetime(0)
|
||||||
|
pool.SetMaxIdleConns(3)
|
||||||
|
pool.SetMaxOpenConns(3)
|
||||||
|
|
||||||
|
ctx, stop := context.WithCancel(context.Background())
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
appSignal := make(chan os.Signal, 3)
|
||||||
|
signal.Notify(appSignal, os.Interrupt)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-appSignal:
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
Ping(ctx)
|
||||||
|
|
||||||
|
Query(ctx, *id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping the database to verify DSN provided by the user is valid and the
|
||||||
|
// server accessible. If the ping fails exit the program with an error.
|
||||||
|
func Ping(ctx context.Context) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := pool.PingContext(ctx); err != nil {
|
||||||
|
log.Fatalf("unable to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query the database for the information requested and prints the results.
|
||||||
|
// If the query fails exit the program with an error.
|
||||||
|
func Query(ctx context.Context, id int64) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var name string
|
||||||
|
err := pool.QueryRowContext(ctx, "select p.name from people as p where p.id = :id;", sql.Named("id", id)).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("unable to execute search query", err)
|
||||||
|
}
|
||||||
|
log.Println("name=", name)
|
||||||
|
}
|
158
src/database/sql/example_service_test.go
Normal file
158
src/database/sql/example_service_test.go
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package sql_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example_openDBService() {
|
||||||
|
// Opening a driver typically will not attempt to connect to the database.
|
||||||
|
db, err := sql.Open("driver-name", "database=test1")
|
||||||
|
if err != nil {
|
||||||
|
// This will not be a connection error, but a DSN parse error or
|
||||||
|
// another initialization error.
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
db.SetConnMaxLifetime(0)
|
||||||
|
db.SetMaxIdleConns(50)
|
||||||
|
db.SetMaxOpenConns(50)
|
||||||
|
|
||||||
|
s := &Service{db: db}
|
||||||
|
|
||||||
|
http.ListenAndServe(":8080", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
db := s.db
|
||||||
|
switch r.URL.Path {
|
||||||
|
default:
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
case "/healthz":
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := s.db.PingContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("db down: %v", err), http.StatusFailedDependency)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
case "/quick-action":
|
||||||
|
// This is a short SELECT. Use the request context as the base of
|
||||||
|
// the context timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
id := 5
|
||||||
|
org := 10
|
||||||
|
var name string
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
select
|
||||||
|
p.name
|
||||||
|
from
|
||||||
|
people as p
|
||||||
|
join organization as o on p.organization = o.id
|
||||||
|
where
|
||||||
|
p.id = :id
|
||||||
|
and o.id = :org
|
||||||
|
;`,
|
||||||
|
sql.Named("id", id),
|
||||||
|
sql.Named("org", org),
|
||||||
|
).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
io.WriteString(w, name)
|
||||||
|
return
|
||||||
|
case "/long-action":
|
||||||
|
// This is a long SELECT. Use the request context as the base of
|
||||||
|
// the context timeout, but give it some time to finish. If
|
||||||
|
// the client cancels before the query is done the query will also
|
||||||
|
// be canceled.
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
rows, err := db.QueryContext(ctx, "select p.name from people as p where p.active = true;")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
err = rows.Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
// Check for errors during rows "Close".
|
||||||
|
// This may be more important if multiple statements are executed
|
||||||
|
// in a single batch and rows were written as well as read.
|
||||||
|
if closeErr := rows.Close(); closeErr != nil {
|
||||||
|
http.Error(w, closeErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for row scan error.
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for errors during row iteration.
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
json.NewEncoder(w).Encode(names)
|
||||||
|
return
|
||||||
|
case "/async-action":
|
||||||
|
// This action has side effects that we want to preserve
|
||||||
|
// even if the client cancels the HTTP request part way through.
|
||||||
|
// For this we do not use the http request context as a base for
|
||||||
|
// the timeout.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var orderRef = "ABC123"
|
||||||
|
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||||
|
_, err = tx.ExecContext(ctx, "stored_proc_name", orderRef)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "action in unknown state, check state before attempting again", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
@ -13,8 +13,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ctx = context.Background()
|
var (
|
||||||
var db *sql.DB
|
ctx context.Context
|
||||||
|
db *sql.DB
|
||||||
|
)
|
||||||
|
|
||||||
func ExampleDB_QueryContext() {
|
func ExampleDB_QueryContext() {
|
||||||
age := 27
|
age := 27
|
||||||
@ -24,13 +26,25 @@ func ExampleDB_QueryContext() {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
names := make([]string, 0)
|
names := make([]string, 0)
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var name string
|
||||||
if err := rows.Scan(&name); err != nil {
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
// Check for a scan error.
|
||||||
|
// Query rows will be closed with defer.
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
|
// If the database is being written to ensure to check for Close
|
||||||
|
// errors that may be returned from the driver. The query may
|
||||||
|
// encounter an auto-commit error and be forced to rollback changes.
|
||||||
|
rerr := rows.Close()
|
||||||
|
if rerr != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rows.Err will report the last error encountered by Rows.Scan.
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -44,11 +58,11 @@ func ExampleDB_QueryRowContext() {
|
|||||||
err := db.QueryRowContext(ctx, "SELECT username, created_at FROM users WHERE id=?", id).Scan(&username, &created)
|
err := db.QueryRowContext(ctx, "SELECT username, created_at FROM users WHERE id=?", id).Scan(&username, &created)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
log.Printf("No user with id %d", id)
|
log.Printf("no user with id %d\n", id)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.Fatal(err)
|
log.Fatalf("query error: %v\n", err)
|
||||||
default:
|
default:
|
||||||
fmt.Printf("Username is %s, account created on %s\n", username, created)
|
log.Printf("username is %q, account created on %s\n", username, created)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +77,7 @@ func ExampleDB_ExecContext() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if rows != 1 {
|
if rows != 1 {
|
||||||
panic(err)
|
log.Fatalf("expected to affect 1 row, affected %d", rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,10 +118,10 @@ from
|
|||||||
if err := rows.Scan(&id, &name); err != nil {
|
if err := rows.Scan(&id, &name); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Printf("id %d name is %s\n", id, name)
|
log.Printf("id %d name is %s\n", id, name)
|
||||||
}
|
}
|
||||||
if !rows.NextResultSet() {
|
if !rows.NextResultSet() {
|
||||||
log.Fatal("expected more result sets", rows.Err())
|
log.Fatalf("expected more result sets: %v", rows.Err())
|
||||||
}
|
}
|
||||||
var roleMap = map[int64]string{
|
var roleMap = map[int64]string{
|
||||||
1: "user",
|
1: "user",
|
||||||
@ -122,7 +136,7 @@ from
|
|||||||
if err := rows.Scan(&id, &role); err != nil {
|
if err := rows.Scan(&id, &role); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Printf("id %d has role %s\n", id, roleMap[role])
|
log.Printf("id %d has role %s\n", id, roleMap[role])
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@ -130,11 +144,23 @@ from
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExampleDB_PingContext() {
|
func ExampleDB_PingContext() {
|
||||||
|
// Ping and PingContext may be used to determine if communication with
|
||||||
|
// the database server is still possible.
|
||||||
|
//
|
||||||
|
// When used in a command line application Ping may be used to establish
|
||||||
|
// that further queries are possible; that the provided DSN is valid.
|
||||||
|
//
|
||||||
|
// When used in long running service Ping may be part of the health
|
||||||
|
// checking system.
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
status := "up"
|
||||||
if err := db.PingContext(ctx); err != nil {
|
if err := db.PingContext(ctx); err != nil {
|
||||||
log.Fatal(err)
|
status = "down"
|
||||||
}
|
}
|
||||||
|
log.Println(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExampleConn_BeginTx() {
|
func ExampleConn_BeginTx() {
|
||||||
@ -162,7 +188,7 @@ func ExampleConn_ExecContext() {
|
|||||||
}
|
}
|
||||||
defer conn.Close() // Return the connection to the pool.
|
defer conn.Close() // Return the connection to the pool.
|
||||||
id := 41
|
id := 41
|
||||||
result, err := conn.ExecContext(ctx, `UPDATE balances SET balance = balance + 10 WHERE user_id = ?`, id)
|
result, err := conn.ExecContext(ctx, `UPDATE balances SET balance = balance + 10 WHERE user_id = ?;`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -171,7 +197,7 @@ func ExampleConn_ExecContext() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if rows != 1 {
|
if rows != 1 {
|
||||||
panic(err)
|
log.Fatalf("expected single row affected, got %d rows affected", rows)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -184,9 +210,9 @@ func ExampleTx_ExecContext() {
|
|||||||
_, execErr := tx.ExecContext(ctx, "UPDATE users SET status = ? WHERE id = ?", "paid", id)
|
_, execErr := tx.ExecContext(ctx, "UPDATE users SET status = ? WHERE id = ?", "paid", id)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
log.Printf("Could not roll back: %v\n", rollbackErr)
|
log.Fatalf("update failed: %v, unable to rollback: %v\n", execErr, rollbackErr)
|
||||||
}
|
}
|
||||||
log.Fatal(execErr)
|
log.Fatalf("update failed: %v", execErr)
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@ -199,17 +225,17 @@ func ExampleTx_Rollback() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
id := 53
|
id := 53
|
||||||
_, err = tx.ExecContext(ctx, "UPDATE drivers SET status = ? WHERE id = ?", "assigned", id)
|
_, err = tx.ExecContext(ctx, "UPDATE drivers SET status = ? WHERE id = ?;", "assigned", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
log.Printf("Could not roll back: %v\n", rollbackErr)
|
log.Fatalf("update drivers: unable to rollback: %v", rollbackErr)
|
||||||
}
|
}
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err = tx.ExecContext(ctx, "UPDATE pickups SET driver_id = $1", id)
|
_, err = tx.ExecContext(ctx, "UPDATE pickups SET driver_id = $1;", id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||||
log.Printf("Could not roll back: %v\n", rollbackErr)
|
log.Fatalf("update failed: %v, unable to back: %v", err, rollbackErr)
|
||||||
}
|
}
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -225,17 +251,18 @@ func ExampleStmt() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
|
|
||||||
// Then reuse it each time you need to issue the query.
|
// Then reuse it each time you need to issue the query.
|
||||||
id := 43
|
id := 43
|
||||||
var username string
|
var username string
|
||||||
err = stmt.QueryRowContext(ctx, id).Scan(&username)
|
err = stmt.QueryRowContext(ctx, id).Scan(&username)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
log.Printf("No user with that ID.")
|
log.Fatalf("no user with id %d", id)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
default:
|
default:
|
||||||
fmt.Printf("Username is %s\n", username)
|
log.Printf("username is %s\n", username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,17 +272,19 @@ func ExampleStmt_QueryRowContext() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
// Then reuse it each time you need to issue the query.
|
// Then reuse it each time you need to issue the query.
|
||||||
id := 43
|
id := 43
|
||||||
var username string
|
var username string
|
||||||
err = stmt.QueryRowContext(ctx, id).Scan(&username)
|
err = stmt.QueryRowContext(ctx, id).Scan(&username)
|
||||||
switch {
|
switch {
|
||||||
case err == sql.ErrNoRows:
|
case err == sql.ErrNoRows:
|
||||||
log.Printf("No user with that ID.")
|
log.Fatalf("no user with id %d", id)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
default:
|
default:
|
||||||
fmt.Printf("Username is %s\n", username)
|
log.Printf("username is %s\n", username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,6 +295,7 @@ func ExampleRows() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
names := make([]string, 0)
|
names := make([]string, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var name string
|
||||||
@ -274,8 +304,9 @@ func ExampleRows() {
|
|||||||
}
|
}
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
|
// Check for errors from iterating over rows.
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
fmt.Printf("%s are %d years old", strings.Join(names, ", "), age)
|
log.Printf("%s are %d years old", strings.Join(names, ", "), age)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user