439 lines
11 KiB
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, ¤t); 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 ctx.Err()
|
|
}
|
|
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
|
|
}
|