servicectx

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 12, 2022 License: MIT Imports: 6 Imported by: 2

README

servicectx: custom context propagation across microservices through HTTP headers, query strings, and OpenTelemetry

Actions Status codecov Go Report Card

A common issue in (micro)services architecture is exchanging and overriding some arbitrary properties across the service chain. While existing tools like OpenTelemetry do provide an underlying infrastructure for that, there is a lack of conventions on how to use them at the application level.

This library aims to ease the inter-service communication, development, and testing by

  • Defining a standard key format for properties meant for propagation: x-service-{SERVICE_NAME}-{OPTION_NAME}
    • This way it is easily distinguishable who the property is intended for. When a service receives such property, it can decide to act on it by reconfiguring itself (if it has something to do with {SERVICE_NAME}), or just pass it further.
  • Providing a convenient way of passing the properties via
    • HTTP headers (e.g. x-service-api-branch: feature-123)
    • Query strings (e.g. ?x-service-api-version=2)
    • OpenTelemetry/OpenTracing baggage
    • context.Context, within a single Go process / request handler

Use cases

One notable use case is dynamic routing. Say you're developing a new version of a billing service http://billing-v2 that is usually called by the backend at http://billing-v1. With servicectx, you can conveniently propagate a custom billing URL without modifying the backend or user interface:

  • First, find a way to pass a custom billing URL to the backend.
    • If using a web page, add a query parameter ?x-service-billing-url=billing-v2 or x-service-billing-url: billing-v2 header (some browser extensions can help).
  • Use servicectx on the backend to parse this property from query string and/or headers.
    • For example: billingUrl := servicectx.FromRequest(req).Get("billing", "url", "billing-v1")
    • Which means: if there's a x-service-billing-url header, then use its value; otherwise, use billing-v1 by default.
  • Call this billing URL instead of the hardcoded one...
  • ...but also propagate all the properties received by the backend to your billing service as well.
    • servicectx can inject them into OpenTelemetry/OpenTracing baggage, or just pass via HTTP headers.

In real life, service chains can be longer and more complex. What if you had to modify the configuration of 5 other services manually just to replace http://billing-v1 with a new URL? When it comes to that, it seems reasonable to implement a common, standard solution for properties propagation across all these services. Take a look at a more detailed example here.

The usage is not limited to just dynamic routing. servicectx can help propagate any ancillary data and make any property dynamically reconfigurable (if your application code can do that, of course). How about increasing log verbosity for a specific request with, say, x-service-api-log-level: debug? The possibilities in ever-growing microservices architecture seem infinite.

This library is inspired in part by an article from DoorDash on OpenTelemetry for custom context propagation.

Usage

Retrieving properties from request
import "github.com/kolesa-team/servicectx"

func testHandler(w http.ResponseWriter, r *http.Request) {
	props := servicectx.FromRequest(r)

	// read an API version from request, or use 1.0 by default
	apiVersion := props.Get("api", "version", "1.0")
	fmt.Printf("API version: %s\n")
}

// Output if no extra headers were sent:
// API version: 1.0

// Output if a header "x-service-api-version: 2.1" was sent
// API version: 2.1
Passing properties via Go context
import "github.com/kolesa-team/servicectx"

func testHandlerWithContext(w http.ResponseWriter, r *http.Request) {
	// parse properties from request and add them to a context.
	// it's ok if no special headers or query args were sent: an empty struct is then used instead.
	ctx := servicectx.InjectIntoContextFromRequest(r.Context(), r.Header)

	// a remoteCall is probably defined in another package;
	// its `username` argument is a part of business logic,
	// but custom context is passed in `ctx` as an ancillary data.
	remoteCall := func(ctx context.Context, username string) string {
            // options are retrieved from a context
            props := servicectx.FromContext(ctx)
            // the remote API address is taken from these props (or default URL is used instead).
            url := props.Get("api", "url", "http://api")
            url += "?username=" + username
            apiRequest, _ := http.NewRequest("GET", url, nil)
            // the properties are propagated further within the headers
            props.InjectIntoHeaders(apiRequest.Header)
            
            // TODO: execute remote call
            // _, _ = http.DefaultClient.Do(apiRequest)
            
            return fmt.Sprintf("Calling remote API at %s with headers:\n%+v", url, apiRequest.Header)
	}

	w.Write([]byte(remoteCall(ctx, r.URL.Query().Get("username"))))
}

Calling the handler above with curl will get us:

$ curl --header "x-service-api-url: http://my-custom-api" --header "x-service-billing-branch: hotfix-123" http://localhost?username=Mary

Calling remote API at http://my-custom-api?username=Mary with headers:
map[X-Service-Api-Url:[http://my-custom-api] X-Service-Billing-Branch:[hotfix-123]]
Dynamic routing: replacing branch name in URL

Another typical scenario is dynamic replacement of branch names in URLs. The library offers a helper function to make URLs easily configurable:

  • Say, the project calls http://billing-main by default, where main is a branch name.
  • We propose to store that address as http://billing-$branch instead, where $branch is a placeholder to be replaced.
  • Then use x-service-billing-branch: my-branch property and call servicectx.ReplaceUrlBranch helper function to reconfigure a URL on the fly:
import "github.com/kolesa-team/servicectx"

func testHandler(w http.ResponseWriter, r *http.Request) {
    props := servicectx.FromRequest(r)
	// retrieve a `billing` service branch, or use `main` by  default
	billingBranch := props.Get("billing", "branch", "main")
	// replace `$branch` with billingBranch
	billingUrl := servicectx.ReplaceUrlBranch("http://billing-$branch", billingBranch)
	
	fmt.Println(billingUrl)	
	
	// curl --header "x-service-billing-branch: bugfix-123" http://localhost
	// -> http://billing-bugfix-123	
}
OpenTelemetry and OpenTracing

Custom properties can be written to and read from telemetry contexts.

FromContextAndBaggage (and its opentracing counterpart, FromContextAndSpan) extracts custom properties from baggage or span. InjectIntoBaggage (and InjectIntoSpan) injects them into baggage or span.

See examples in kolesa-team/servicectx/otel, kolesa-team/servicectx/opentracing.

Interacting with properties
props := servicectx.New()
props.Set("api", "branch", "feature-123")

// retrieve the properties as a map of HTTP headers
fmt.Printf("%+v", props.HeaderMap())
// map[x-service-api-branch:feature-123]

// read integer value (or use 1 as a default)
props.Set("api", "version", "2")
fmt.Println(props.GetInt("api", "version", 1))
// 2

// read time.Duration (or use 1 second as a default)
props.Set("api", "timeout", "3s")
fmt.Println(props.GetDuration("api", "timeout", time.Second))
// 3s

Advantages

  • A simple format. x-service-{SERVICE_NAME}-{OPTION} can be easily parsed in any programming language, if you need it.
  • Supports OpenTelemetry and OpenTracing...
  • ...but can also be used as a standalone solution.
  • The properties from multiple sources can be merged (e.g. an HTTP header can take preference over the same property from OpenTracing baggage).
  • No external dependencies (except OpenTracing/OpenTelemetry, when you need them).

Concerns

  • Service names in x-service-{SERVICE_NAME}-{OPTION} cannot contain - sign (which is used as a separator). The format is not configurable for the sake of simplicity.
    • If someone really wants to, it is probably possible to introduce custom format without breaking the compatibility.
  • The library can't "un-hardcode" your project configuration automagically. Overriding some properties per-request in application code (such as HTTP URLs) is trivial, and some (like database hosts) is not.
  • Clearly, accepting arbitrary configuration from user input is a security violation. An application code is responsible for disabling this functionality in production.

servicectx: передача контекста между сервисами через заголовки HTTP, query-параметры или OpenTelemetry

При разработке и тестировании в микросервисной архитектуре часто возникает задача передачи и переопределения произвольных опций в цепочке сервисов. Существующие решения вроде OpenTelemetry предоставляют для этого техническую инфраструктуру, но на практике ощущается недостаток соглашений или стандартов по их использованию в бизнес-логике.

Задачи библиотеки:

  • Описать стандартный формат ключей для межсервисного взаимодействия: x-service-{SERVICE_NAME}-{OPTION_NAME}
    • Такой формат позволяет легко понять, для какого именно сервиса предназначен ключ. При получении ключа сервис может отреагировать на него (если у него подходящий {SERVICE_NAME}), переконфигурировав себя, либо просто прокинуть это свойство дальше.
  • Предоставить удобные способы передачи таких данных через
    • Заголовки HTTP (например, x-service-api-branch: feature-123)
    • Query-параметры (например, ?x-service-api-version=2)
    • Метаданные (baggage) OpenTelemetry/OpenTracing
    • или через context.Context для передачи в рамках одного процесса Go

Зачем это может понадобиться? Интересный вариант использования - это динамический роутинг. Например, мы разрабатываем новую версию сервиса платежей http://billing-v2, в то время как зависимый от него бэкенд обращается к http://billing-v1. С помощью servicectx можно удобно переопределить адрес биллинга, не меняя зависимые проекты и пользовательский интерфейс, чтобы быстро протестировать интеграцию всех сервисов:

  • Сначала нужно передать новый адрес сервиса платежей на бэкенд
    • Если речь о веб-странице, то можно добавить в адресную строку параметр ?x-service-billing-url=billing-v2 или установить заголовок x-service-billing-url: billing-v2 (с этим могут помочь браузерные расширения).
  • Использовать servicectx на бэкенде для парсинга опций из запроса.
    • Например: billingUrl := servicectx.FromRequest(req).Get("billing", "url", "billing-v1")
    • Это означает: если пришёл заголовок x-service-billing-url, то используем его значение как адрес биллинга; иначе используем billing-v1 по-умолчанию.
  • Вызвать сервис платежей по этому адресу (вместо использования захардкоженного адреса).
  • Также хорошим решением будет прокинуть весь контекст, полученный бэкендом из интерфейса, в сервис биллинга.
    • servicectx может внедрить его в трейс OpenTelemetry/OpenTracing через baggage, или просто передать в HTTP заголовках также, как это было сделано на первом шаге.
    • Это позволит контексту распространиться дальше, по всей цепочке вызовов.

В реальности цепочки вызовов сервисов бывают более длинными и сложными. Что если нам пришлось бы вручную менять конфигурацию пяти разных проектов просто чтобы заменить http://billing-v1 на новый URL? В такой ситуации разумно внедрить общее, стандартное решение для передачи контекста между всеми проектами и избавиться от необходимости вносить изменения вручную.

Использование библиотеки не ограничивается динамическим роутингом. servicectx поможет принять и передать любые служебные данные и сделать любое свойство конфигурируемым (если код приложения сможет с этим работать). Например, можно реализовать изменение уровня логирования в рамках одного запроса через заголовок типа x-service-api-log-level: debug.


© 2022 Kolesa Group. Licensed under MIT

Documentation

Overview

Package servicectx facilitates exchanging arbitrary properties across microservices via HTTP headers, query strings, OpenTelemetry/OpenTracing baggage, and/or Go Context.

Index

Examples

Constants

View Source
const NamePrefix = "x-service"

NamePrefix a prefix at the beginning of a property name indicating it belongs to this package

View Source
const Separator = "-"
View Source
const UrlBranchPlaceholder = "$branch"

UrlBranchPlaceholder is a part of URL to be replaced with a branch name

Variables

This section is empty.

Functions

func GetPropertyName added in v0.2.0

func GetPropertyName(serviceName, option string) string

GetPropertyName builds a string from service name and property name

func InjectIntoContextFromRequest added in v0.2.0

func InjectIntoContextFromRequest(ctx context.Context, req *http.Request) context.Context

InjectIntoContextFromRequest parses properties from request and adds them into context

Example

An example HTTP client and server, with properties exchanged internally through `context.Context`

props := New()
// the "api url" will be used by the handler below
props.Set("api", "url", "http://my-custom-api")
// and the "billing branch" will just be passed downstream to the remote service
props.Set("billing", "branch", "hotfix-123")

req := httptest.NewRequest(http.MethodGet, "/?username=Alex", nil)
props.InjectIntoHeaders(req.Header)

handler := func(w http.ResponseWriter, r *http.Request) {
	// parse properties from request and add them to a context.
	// it's ok if no special headers or query args were sent: an empty usable struct is then used instead.
	ctx := InjectIntoContextFromRequest(r.Context(), r)

	// an apiCall is probably defined in another package;
	// its `username` argument is a part of business logic,
	// but properties, being an arbitrary ancillary data, are passed within the context.
	apiCall := func(ctx context.Context, username string) string {
		// props are retrieved from a context
		props := FromContext(ctx)
		// the remote API address is taken from these props (or default URL is used instead).
		url := props.Get("api", "url", "http://api")
		url += "?username=" + username
		apiRequest, _ := http.NewRequest("GET", url, nil)
		// the properties are propagated further within the headers
		props.InjectIntoHeaders(apiRequest.Header)

		// ...execute remote call
		// _, _ = http.DefaultClient.Do(apiRequest)

		return fmt.Sprintf("Calling remote API at %s with headers:\n%+v", url, apiRequest.Header)
	}

	// execute remote call and print the results back
	w.Write([]byte(apiCall(ctx, r.URL.Query().Get("username"))))
}

w := httptest.NewRecorder()
handler(w, req)
res := w.Result()
responseBytes, _ := ioutil.ReadAll(res.Body)

fmt.Println(string(responseBytes))
Output:

Calling remote API at http://my-custom-api?username=Alex with headers:
map[X-Service-Api-Url:[http://my-custom-api] X-Service-Billing-Branch:[hotfix-123]]

func InjectIntoHeadersFromContext

func InjectIntoHeadersFromContext(ctx context.Context, header http.Header)

InjectIntoHeadersFromContext adds properties from context into http.Header

func ParsePropertyName added in v0.2.0

func ParsePropertyName(name string) (serviceName, option string, ok bool)

ParsePropertyName parses a string like "x-service-api-branch" into service name ("api"), property name ("branch"), and a boolean success flag

func ReplaceUrlBranch

func ReplaceUrlBranch(url, branch string) string

ReplaceUrlBranch replaces branch placeholder in URL with an actual branch name.

Types

type Properties added in v0.2.0

type Properties map[string]Values

Properties grouped by a service name

func FromContext

func FromContext(ctx context.Context) Properties

FromContext returns properties from context. If there are no properties in the context, an empty usable instance is returned.

func FromHeaders

func FromHeaders(headers http.Header) Properties

FromHeaders constructs properties from HTTP headers

func FromQueryString

func FromQueryString(query string) Properties

FromQueryString parses properties from an HTTP query string

func FromQueryValues added in v0.2.0

func FromQueryValues(values url.Values) Properties

FromQueryValues parses properties from a parsed HTTP query string

func FromRequest added in v0.2.0

func FromRequest(req *http.Request) Properties

FromRequest constructs properties from HTTP headers and query string of the request. Query string properties have a priority over HTTP headers.

Example

An example HTTP client and server that exchange properties via HTTP headers

// a client defines some properties to be sent to a server
props := New()
props.Set("api", "version", "1.0.1")
props.Set("billing", "branch", "bugfix-123")

req := httptest.NewRequest(http.MethodGet, "/", nil)
// insert the props into request headers
props.InjectIntoHeaders(req.Header)

// a simple request handler that parses incoming properties and prints them back in response
handler := func(w http.ResponseWriter, r *http.Request) {
	props := FromHeaders(r.Header)

	// print an API version, or use a default version 1.0.0
	w.Write([]byte(fmt.Sprintf("API version: %s\n", props.Get("api", "version", "1.0.0"))))

	// configure a billing service URL with a custom branch, or use a default `main` branch
	billingServiceUrl := ReplaceUrlBranch(
		"http://billing-$branch",
		props.Get("billing", "branch", "main"),
	)

	w.Write([]byte(fmt.Sprintf("Billing service url: %s\n", billingServiceUrl)))
}

// call the handler above
w := httptest.NewRecorder()
handler(w, req)
res := w.Result()
responseBytes, _ := ioutil.ReadAll(res.Body)

fmt.Println(string(responseBytes))
Output:

API version: 1.0.1
Billing service url: http://billing-bugfix-123

func New

func New() Properties

New constructs a new properties instance

func (Properties) Get added in v0.2.0

func (p Properties) Get(serviceName, prop, defaultValue string) string

Get returns an property value for a given service

func (Properties) GetBool added in v0.3.0

func (p Properties) GetBool(serviceName, prop string, defaultValue bool) bool

GetBool returns a property value for a given service as boolean

func (Properties) GetByService added in v0.2.0

func (p Properties) GetByService(serviceName string) Values

GetByService returns all options for a given service

func (Properties) GetDuration added in v0.2.0

func (p Properties) GetDuration(serviceName, prop string, defaultValue time.Duration) time.Duration

GetDuration returns a property value for a given service as time.Duration

func (Properties) GetInt added in v0.2.0

func (p Properties) GetInt(serviceName, prop string, defaultValue int) int

GetInt returns a property value for a given service as an integer

func (Properties) HasProperty added in v0.2.0

func (p Properties) HasProperty(serviceName, option string) bool

HasProperty checks if a given property exists for a service

func (Properties) HasService added in v0.2.0

func (p Properties) HasService(serviceName string) bool

HasService checks if there are options for a given service

func (Properties) HeaderMap added in v0.2.0

func (p Properties) HeaderMap() map[string]string

HeaderMap returns options as a map of HTTP headers

func (Properties) InjectIntoContext added in v0.2.0

func (p Properties) InjectIntoContext(ctx context.Context) context.Context

InjectIntoContext adds properties to the context

func (Properties) InjectIntoHeaders added in v0.2.0

func (p Properties) InjectIntoHeaders(headers http.Header)

InjectIntoHeaders adds property headers to http.Header

func (Properties) Merge added in v0.2.0

func (p Properties) Merge(other Properties) Properties

Merge merges two sets of properties. The receiver is modified and returned for chaining.

func (Properties) QueryString added in v0.2.0

func (p Properties) QueryString() string

QueryString converts properties to an HTTP query string

func (Properties) QueryValues added in v0.2.0

func (p Properties) QueryValues() url.Values

QueryValues converts properties to a set of HTTP query parameters

func (Properties) Set added in v0.2.0

func (p Properties) Set(serviceName, prop, value string) Properties

Set sets a property value for a given service

type Values

type Values map[string]string

Values key => value properties

Directories

Path Synopsis
opentracing module
otel module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL