Files
configcenter/pkg/sdk/go/client.go

439 lines
11 KiB
Go

// Package configsdk provides a concurrency-safe Config Center client with
// full-snapshot synchronization and an optional local-file fallback cache.
package configsdk
import (
"bufio"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
configcenterv1 "github.com/longpeng/configcenter/pkg/proto/v1"
)
type Options struct {
BaseURL string
Env string
App string
Token string
IP string
Instance string
CacheFile string
HTTPClient *http.Client
}
type GRPCOptions struct {
Target string
Env string
App string
Token string
IP string
Instance string
CacheFile string
DialOptions []grpc.DialOption
}
type Client struct {
baseURL string
env string
app string
token string
ip string
instance string
cacheFile string
http *http.Client
grpcConn *grpc.ClientConn
grpc configcenterv1.ConfigServiceClient
mu sync.RWMutex
cache map[string]map[string]string
revisions map[string]int64
}
type apiEnvelope struct {
Data json.RawMessage `json:"data"`
}
type runtimeConfig struct {
Items map[string]string `json:"items"`
Revision int64 `json:"revision"`
ReleaseVersion int `json:"releaseVersion"`
}
type configEvent struct {
Type string `json:"type"`
Items map[string]string `json:"items"`
Revision int64 `json:"revision"`
}
type diskCache struct {
Namespaces map[string]map[string]string `json:"namespaces"`
Revisions map[string]int64 `json:"revisions"`
}
func New(options Options) (*Client, error) {
if strings.TrimSpace(options.BaseURL) == "" || strings.TrimSpace(options.Env) == "" || strings.TrimSpace(options.App) == "" {
return nil, errors.New("base URL, environment and application are required")
}
httpClient := options.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
client := &Client{
baseURL: strings.TrimRight(options.BaseURL, "/"),
env: strings.TrimSpace(options.Env),
app: strings.TrimSpace(options.App),
token: strings.TrimSpace(options.Token),
ip: strings.TrimSpace(options.IP),
instance: strings.TrimSpace(options.Instance),
cacheFile: options.CacheFile,
http: httpClient,
cache: make(map[string]map[string]string),
revisions: make(map[string]int64),
}
if options.CacheFile != "" {
if err := client.loadDiskCache(); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("load config cache: %w", err)
}
}
return client, nil
}
func NewGRPC(options GRPCOptions) (*Client, error) {
if strings.TrimSpace(options.Target) == "" || strings.TrimSpace(options.Env) == "" || strings.TrimSpace(options.App) == "" {
return nil, errors.New("gRPC target, environment and application are required")
}
dialOptions := append([]grpc.DialOption(nil), options.DialOptions...)
if len(dialOptions) == 0 {
dialOptions = append(dialOptions, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12})))
}
connection, err := grpc.NewClient(strings.TrimSpace(options.Target), dialOptions...)
if err != nil {
return nil, fmt.Errorf("connect config center gRPC: %w", err)
}
client := &Client{
env: strings.TrimSpace(options.Env),
app: strings.TrimSpace(options.App),
token: strings.TrimSpace(options.Token),
ip: strings.TrimSpace(options.IP),
instance: strings.TrimSpace(options.Instance),
cacheFile: options.CacheFile,
grpcConn: connection,
grpc: configcenterv1.NewConfigServiceClient(connection),
cache: make(map[string]map[string]string),
revisions: make(map[string]int64),
}
if options.CacheFile != "" {
if err := client.loadDiskCache(); err != nil && !errors.Is(err, os.ErrNotExist) {
_ = connection.Close()
return nil, fmt.Errorf("load config cache: %w", err)
}
}
return client, nil
}
func (c *Client) Close() error {
if c.grpcConn != nil {
return c.grpcConn.Close()
}
return nil
}
func (c *Client) GetString(namespace, key, fallback string) string {
c.mu.RLock()
defer c.mu.RUnlock()
if items, ok := c.cache[namespace]; ok {
if value, exists := items[key]; exists {
return value
}
}
return fallback
}
func (c *Client) GetInt(namespace, key string, fallback int) int {
value := c.GetString(namespace, key, "")
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func (c *Client) GetBool(namespace, key string, fallback bool) bool {
value := c.GetString(namespace, key, "")
parsed, err := strconv.ParseBool(value)
if err != nil {
return fallback
}
return parsed
}
func (c *Client) Snapshot(namespace string) map[string]string {
c.mu.RLock()
defer c.mu.RUnlock()
return clone(c.cache[namespace])
}
func (c *Client) Load(ctx context.Context, namespace string) error {
if c.grpc != nil {
return c.loadGRPC(ctx, namespace)
}
endpoint := c.endpoint("/v1/config", namespace)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
c.authorize(request)
response, err := c.http.Do(request)
if err != nil {
return err
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
return responseError(response)
}
var envelope apiEnvelope
if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil {
return err
}
var current runtimeConfig
if err := json.Unmarshal(envelope.Data, &current); err != nil {
return err
}
return c.apply(namespace, current.Items, current.Revision)
}
// WatchAndSync blocks until ctx is canceled. It reconnects with exponential
// backoff and replaces the namespace cache only with complete snapshots.
func (c *Client) WatchAndSync(ctx context.Context, namespace string) error {
backoff := time.Second
for {
err := c.watchOnce(ctx, namespace)
if ctx.Err() != nil {
return nil
}
if err == nil {
backoff = time.Second
}
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
timer.Stop()
return nil
case <-timer.C:
}
if backoff < 30*time.Second {
backoff *= 2
}
}
}
func (c *Client) watchOnce(ctx context.Context, namespace string) error {
if c.grpc != nil {
return c.watchGRPCOnce(ctx, namespace)
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.endpoint("/v1/watch", namespace), nil)
if err != nil {
return err
}
request.Header.Set("Accept", "text/event-stream")
c.authorize(request)
response, err := c.http.Do(request)
if err != nil {
return err
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
return responseError(response)
}
scanner := bufio.NewScanner(response.Body)
scanner.Buffer(make([]byte, 64*1024), 10*1024*1024)
var data strings.Builder
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if data.Len() > 0 {
var event configEvent
if err := json.Unmarshal([]byte(data.String()), &event); err != nil {
return err
}
if err := c.apply(namespace, event.Items, event.Revision); err != nil {
return err
}
data.Reset()
}
continue
}
if strings.HasPrefix(line, "data:") {
if data.Len() > 0 {
data.WriteByte('\n')
}
data.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if err := scanner.Err(); err != nil {
return err
}
return io.EOF
}
func (c *Client) loadGRPC(ctx context.Context, namespace string) error {
response, err := c.grpc.GetConfig(c.grpcContext(ctx), &configcenterv1.GetConfigRequest{
Env: c.env, App: c.app, Namespace: namespace, Ip: c.ip, Instance: c.instance,
})
if err != nil {
return err
}
return c.apply(namespace, protoItemMap(response.GetItems()), response.GetRevision())
}
func (c *Client) watchGRPCOnce(ctx context.Context, namespace string) error {
c.mu.RLock()
lastRevision := c.revisions[namespace]
c.mu.RUnlock()
startRevision := int64(0)
if lastRevision > 0 {
startRevision = lastRevision + 1
}
stream, err := c.grpc.WatchConfig(c.grpcContext(ctx), &configcenterv1.WatchConfigRequest{
Env: c.env, App: c.app, Namespace: namespace, StartRevision: startRevision, Ip: c.ip, Instance: c.instance,
})
if err != nil {
return err
}
for {
event, err := stream.Recv()
if err != nil {
return err
}
if err := c.apply(namespace, protoItemMap(event.GetItems()), event.GetRevision()); err != nil {
return err
}
}
}
func (c *Client) grpcContext(ctx context.Context) context.Context {
if c.token == "" {
return ctx
}
return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+c.token)
}
func protoItemMap(items []*configcenterv1.ConfigItem) map[string]string {
result := make(map[string]string, len(items))
for _, item := range items {
result[item.GetKey()] = item.GetValue()
}
return result
}
func (c *Client) endpoint(path, namespace string) string {
query := url.Values{"env": {c.env}, "app": {c.app}, "namespace": {namespace}}
if c.ip != "" {
query.Set("ip", c.ip)
}
if c.instance != "" {
query.Set("instance", c.instance)
}
return c.baseURL + path + "?" + query.Encode()
}
func (c *Client) authorize(request *http.Request) {
if c.token != "" {
request.Header.Set("Authorization", "Bearer "+c.token)
}
}
func (c *Client) apply(namespace string, items map[string]string, revision int64) error {
c.mu.Lock()
if revision > 0 && revision < c.revisions[namespace] {
c.mu.Unlock()
return nil
}
c.cache[namespace] = clone(items)
c.revisions[namespace] = revision
err := c.saveDiskCacheLocked()
c.mu.Unlock()
return err
}
func (c *Client) loadDiskCache() error {
payload, err := os.ReadFile(c.cacheFile)
if err != nil {
return err
}
var cached diskCache
if err := json.Unmarshal(payload, &cached); err != nil {
return err
}
if cached.Namespaces != nil {
c.cache = cached.Namespaces
}
if cached.Revisions != nil {
c.revisions = cached.Revisions
}
return nil
}
func (c *Client) saveDiskCacheLocked() error {
if c.cacheFile == "" {
return nil
}
payload, err := json.MarshalIndent(diskCache{Namespaces: c.cache, Revisions: c.revisions}, "", " ")
if err != nil {
return err
}
directory := filepath.Dir(c.cacheFile)
if err := os.MkdirAll(directory, 0o700); err != nil {
return err
}
temporary, err := os.CreateTemp(directory, ".configcenter-cache-*")
if err != nil {
return err
}
temporaryName := temporary.Name()
defer os.Remove(temporaryName) //nolint:errcheck
defer func() { _ = temporary.Close() }()
if err := temporary.Chmod(0o600); err != nil {
return err
}
if _, err := temporary.Write(payload); err != nil {
return err
}
if err := temporary.Sync(); err != nil {
return err
}
if err := temporary.Close(); err != nil {
return err
}
return os.Rename(temporaryName, c.cacheFile)
}
func responseError(response *http.Response) error {
payload, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
return fmt.Errorf("config center returned %s: %s", response.Status, strings.TrimSpace(string(payload)))
}
func clone(input map[string]string) map[string]string {
result := make(map[string]string, len(input))
for key, value := range input {
result[key] = value
}
return result
}