cloudsql/postgres/database-sql/cloudsql.go (277 lines of code) (raw):
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Sample database-sql demonstrates connecting to a Cloud SQL instance.
// The application is a Go version of the "Tabs vs Spaces"
// web app presented at Google Cloud Next 2019 as seen in this video:
// https://www.youtube.com/watch?v=qVgzP3PsXFw&t=1833s
package cloudsql
import (
"database/sql"
"fmt"
"html/template"
"log"
"math"
"net/http"
"os"
"sync"
"time"
)
var (
indexTmpl = template.Must(template.New("index").Parse(indexHTML))
db *sql.DB
once sync.Once
)
// getDB lazily instantiates a database connection pool. Users of Cloud Run or
// Cloud Functions may wish to skip this lazy instantiation and connect as soon
// as the function is loaded. This is primarily to help testing.
func getDB() *sql.DB {
once.Do(func() {
db = mustConnect()
})
return db
}
// migrateDB creates the votes table if it does not already exist.
func migrateDB(db *sql.DB) error {
createVotes := `CREATE TABLE IF NOT EXISTS votes (
id SERIAL NOT NULL,
created_at timestamp NOT NULL,
candidate VARCHAR(6) NOT NULL,
PRIMARY KEY (id)
);`
_, err := db.Exec(createVotes)
return err
}
// recentVotes returns the last five votes cast.
func recentVotes(db *sql.DB) ([]vote, error) {
rows, err := db.Query("SELECT candidate, created_at FROM votes ORDER BY created_at DESC LIMIT 5")
if err != nil {
return nil, fmt.Errorf("DB.Query: %w", err)
}
defer rows.Close()
var votes []vote
for rows.Next() {
var (
candidate string
voteTime time.Time
)
err := rows.Scan(&candidate, &voteTime)
if err != nil {
return nil, fmt.Errorf("Rows.Scan: %w", err)
}
votes = append(votes, vote{Candidate: candidate, VoteTime: voteTime})
}
return votes, nil
}
// formatMargin calculates the difference between votes and returns a human
// friendly margin (e.g., 2 votes)
func formatMargin(a, b int) string {
diff := int(math.Abs(float64(a - b)))
margin := fmt.Sprintf("%d votes", diff)
// remove pluralization when diff is just one
if diff == 1 {
margin = "1 vote"
}
return margin
}
// votingData is used to pass data to the HTML template.
type votingData struct {
TabsCount int
SpacesCount int
VoteMargin string
RecentVotes []vote
}
// currentTotals retrieves all voting data from the database.
func currentTotals(db *sql.DB) (votingData, error) {
var (
tabs int
spaces int
)
err := db.QueryRow("SELECT count(id) FROM votes WHERE candidate='TABS'").Scan(&tabs)
if err != nil {
return votingData{}, fmt.Errorf("DB.QueryRow: %w", err)
}
err = db.QueryRow("SELECT count(id) FROM votes WHERE candidate='SPACES'").Scan(&spaces)
if err != nil {
return votingData{}, fmt.Errorf("DB.QueryRow: %w", err)
}
recent, err := recentVotes(db)
if err != nil {
return votingData{}, fmt.Errorf("recentVotes: %w", err)
}
return votingData{
TabsCount: tabs,
SpacesCount: spaces,
VoteMargin: formatMargin(tabs, spaces),
RecentVotes: recent,
}, nil
}
// mustConnect creates a connection to the database based on environment
// variables. Setting one of INSTANCE_HOST, INSTANCE_UNIX_SOCKET, or
// INSTANCE_CONNECTION_NAME will establish a connection using a TCP socket, a
// Unix socket, or a connector respectively.
func mustConnect() *sql.DB {
var (
db *sql.DB
err error
)
// Use a TCP socket when INSTANCE_HOST (e.g., 127.0.0.1) is defined
if os.Getenv("INSTANCE_HOST") != "" {
db, err = connectTCPSocket()
if err != nil {
log.Fatalf("connectTCPSocket: unable to connect: %s", err)
}
}
// Use a Unix socket when INSTANCE_UNIX_SOCKET (e.g., /cloudsql/proj:region:instance) is defined.
if os.Getenv("INSTANCE_UNIX_SOCKET") != "" {
db, err = connectUnixSocket()
if err != nil {
log.Fatalf("connectUnixSocket: unable to connect: %s", err)
}
}
// Use the connector when INSTANCE_CONNECTION_NAME (proj:region:instance) is defined.
if os.Getenv("INSTANCE_CONNECTION_NAME") != "" {
if os.Getenv("DB_USER") == "" && os.Getenv("DB_IAM_USER") == "" {
log.Fatal("Warning: One of DB_USER or DB_IAM_USER must be defined")
}
// Use IAM Authentication (recommended) if DB_IAM_USER is set
if os.Getenv("DB_IAM_USER") != "" {
db, err = connectWithConnectorIAMAuthN()
} else {
db, err = connectWithConnector()
}
if err != nil {
log.Fatalf("connectConnector: unable to connect: %s", err)
}
}
if db == nil {
log.Fatal("Missing database connection type. Please define one of INSTANCE_HOST, INSTANCE_UNIX_SOCKET, or INSTANCE_CONNECTION_NAME")
}
if err := migrateDB(db); err != nil {
log.Fatalf("unable to create table: %s", err)
}
return db
}
// configureConnectionPool sets database connection pool properties.
// For more information, see https://golang.org/pkg/database/sql
func configureConnectionPool(db *sql.DB) {
// [START cloud_sql_postgres_databasesql_limit]
// Set maximum number of connections in idle connection pool.
db.SetMaxIdleConns(5)
// Set maximum number of open connections to the database.
db.SetMaxOpenConns(7)
// [END cloud_sql_postgres_databasesql_limit]
// [START cloud_sql_postgres_databasesql_lifetime]
// Set Maximum time (in seconds) that a connection can remain open.
db.SetConnMaxLifetime(1800 * time.Second)
// [END cloud_sql_postgres_databasesql_lifetime]
// [START cloud_sql_postgres_databasesql_backoff]
// database/sql does not support specifying backoff
// [END cloud_sql_postgres_databasesql_backoff]
// [START cloud_sql_postgres_databasesql_timeout]
// The database/sql package currently doesn't offer any functionality to
// configure connection timeout.
// [END cloud_sql_postgres_databasesql_timeout]
}
// Votes handles HTTP requests to alternatively show the voting app or to save a
// vote.
func Votes(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
renderIndex(w, r, getDB())
case http.MethodPost:
saveVote(w, r, getDB())
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// vote contains a single row from the votes table in the database. Each vote
// includes a candidate ("TABS" or "SPACES") and a timestamp.
type vote struct {
Candidate string
VoteTime time.Time
}
// renderIndex renders the HTML application with the voting form, current
// totals, and recent votes.
func renderIndex(w http.ResponseWriter, r *http.Request, db *sql.DB) {
t, err := currentTotals(db)
if err != nil {
log.Printf("renderIndex: failed to read current totals: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
err = indexTmpl.Execute(w, t)
if err != nil {
log.Printf("renderIndex: failed to render template: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
// saveVote saves a vote passed as http.Request form data.
func saveVote(w http.ResponseWriter, r *http.Request, db *sql.DB) {
if err := r.ParseForm(); err != nil {
log.Printf("saveVote: failed to parse form: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
team := r.FormValue("team")
if team == "" {
log.Printf("saveVote: \"team\" property missing from form submission")
w.WriteHeader(http.StatusBadRequest)
return
}
if team != "TABS" && team != "SPACES" {
log.Printf("saveVote: \"team\" property should be \"TABS\" or \"SPACES\", was %q", team)
w.WriteHeader(http.StatusBadRequest)
return
}
// [START cloud_sql_postgres_databasesql_connection]
insertVote := "INSERT INTO votes(candidate, created_at) VALUES($1, NOW())"
_, err := db.Exec(insertVote, team)
// [END cloud_sql_postgres_databasesql_connection]
if err != nil {
log.Printf("saveVote: unable to save vote: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
fmt.Fprintf(w, "Vote successfully cast for %s!", team)
}
var indexHTML = `
<html lang="en">
<head>
<title>Tabs VS Spaces</title>
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgo=">
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
</head>
<body>
<nav class="red lighten-1">
<div class="nav-wrapper">
<a href="#" class="brand-logo center">Tabs VS Spaces</a>
</div>
</nav>
<div class="section">
<div class="center">
<h4>
{{ if eq .TabsCount .SpacesCount }}
TABS and SPACES are evenly matched!
{{ else if gt .TabsCount .SpacesCount }}
TABS are winning by {{ .VoteMargin }}
{{ else if gt .SpacesCount .TabsCount }}
SPACES are winning by {{ .VoteMargin }}
{{ end }}
</h4>
</div>
<div class="row center">
<div class="col s6 m5 offset-m1">
{{ if gt .TabsCount .SpacesCount }}
<div class="card-panel green lighten-3">
{{ else }}
<div class="card-panel">
{{ end }}
<i class="material-icons large">keyboard_tab</i>
<h3>{{ .TabsCount }} votes</h3>
<button id="voteTabs" class="btn green">Vote for TABS</button>
</div>
</div>
<div class="col s6 m5">
{{ if lt .TabsCount .SpacesCount }}
<div class="card-panel blue lighten-3">
{{ else }}
<div class="card-panel">
{{ end }}
<i class="material-icons large">space_bar</i>
<h3>{{ .SpacesCount }} votes</h3>
<button id="voteSpaces" class="btn blue">Vote for SPACES</button>
</div>
</div>
</div>
<h4 class="header center">Recent Votes</h4>
<ul class="container collection center">
{{ range .RecentVotes }}
<li class="collection-item avatar">
{{ if eq .Candidate "TABS" }}
<i class="material-icons circle green">keyboard_tab</i>
{{ else if eq .Candidate "SPACES" }}
<i class="material-icons circle blue">space_bar</i>
{{ end }}
<span class="title">
A vote for <b>{{.Candidate}}</b> was cast at {{.VoteTime.Format "2006-01-02T15:04:05Z07:00" }}
</span>
</li>
{{ end }}
</ul>
</div>
<script>
function vote(team) {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (this.readyState == 4) {
window.location.reload();
}
};
xhr.open("POST", "/Votes", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send("team=" + team);
}
document.getElementById("voteTabs").addEventListener("click", function () {
vote("TABS");
});
document.getElementById("voteSpaces").addEventListener("click", function () {
vote("SPACES");
});
</script>
</body>
</html>
`