Если вы видите что-то необычное, просто сообщите мне. Skip to main content

Злоупотребление синтаксисом Go для создания DSL(предметно-ориентированного языка)

Go частый выбор для создания внутреностей высокопроизводительных систем, но так он так же разработан с некоторым количеством функций которые хороши для создания высокоуровневых абстракций. Вам не нужно переключаться на динамические языки такие как Ruby или Python, чтобы получить удовольствие от API или объяснительный синтакс.

Довольно часто для выражения API используется DSL(Предметно ориентированный язык). DSL это язык внутри языка который компилируется или интерпритируется внутри языка, в нашем случае Go. Если API хорошо спроектирован, DSL выглядит как его собственная спецификация специально созданная для конкретной задачи. DSL такие как CSS, SQL созданы как отдельный язык с их собственными анализаторами, но на данный момент мы сосредоточимся на одном, который построим с помощью компилятора Go, и используем внутри Go кода.

DSL используется для инфраструктурной автоматизации, модель объявления данных, построения запросов и тонны других приложений. Он может быть приятен для написания DSL для задач типа "подключил и настроил" потому, что они предлагают декларативный синтакс. Чаще чем инперативное описание, вся логика и операции нужны чтобы создать у приложения определенное состояние. DSL позволяет вам объявить желаемое состояние,параметры этого состояния и их реализацию ниже, всего лишь в шаге друг от друга. Конечный код тоже будет старатся выглядеть проще для понимания.

Мы собираемся посмотреть на то, как построить API, которое будет понятно для Go комплиятора, но выглядеть оно будет как отдельный язык. Название этой статьи выбрано потому, что мы собираемся нарушить дух Go только слегка, чтобы поправить букву закона так как нам хочется. Я надеюсь вы собираетесь быть рассудительны при применении сомнительных практик обсуждаемых далее. Я так же надеюсь изучая их, вы будете вдохновлены думать креативно о том, как писать выразительные Go API с которыми будет приятно работать и легко понимать.

Пример использования.

Мы собираемся построить просто DSL для создания HTTP посредника. Эта область отличный кандидат для DSL потому что оно наполнено общими, хорошо понимаемыми и часто переиспользуемыми шаблонами как ограничени доступа, частого ограничения, обработки сессии и так далее. Лучше всего для читалей и писателей кода который реализует эти шаблоны, если код будет читаться как декларативный файл настроек, чем как императивное изобретение колеса.

Личные типы.

Один из тонких но мощных моментов написания Go кода с образным чувством личности типов. Большую часть времени когда мы объявляем свой тип в Go, мы объявляем структуру или интерфейс. Мы так же можем объявить новый личный тип для существующих, ссылаясь на них с именем которое мы выбрали.

Это может быть чем-то простым как создание нового имени простопу типу, такому как string для названий хостов:

type Host string

Мы также можем создать тип для набора:

type HostList []Host
type HostSet map[Host]interface{}

Теперь где нибудь в нашем коде, переменная типа HostList будет []Host, или на самом деле []string под капотом, но с более понятным имененм.

Выгоду от таких типов, кроме как внешнего вида и сохраненных нажатий клавиш, это то что эти типы могут быть расширены с помощью их собственных типов. На пример:

func (s HostSet) Add(n Host) {
	s[n] = struct{}{}
}

func (s HostSet) Remove(n Host) {
	delete(s, n)
}

func (s HostSet) Contains(n Host) bool {
	_, found := s[n]
	return found
}

Как мы можем использовать HostSet как если бы это был более сложная структура контейнера доступная через методы:

func main() {
	s := make(HostSet)
	s.Add("golang.org")
	s.Add("google.com")
	s.Add("gopheracademy.org")
	s.Remove("google.com")

	hostnames := HostList{
		"golang.org",
		"google.com",
		"gopheracademy.org",
	}
	for _, n := range hostnames {
		fmt.Printf("%s? %v\n", n, s.Contains(n))
	}
}

Вот что мы получим на выходе:

golang.org? true
google.com? false
gopheracademy.org? true

Что мы тут имеем? Мы созадли абстракцию над простой мапой, мы можем использовать её как набор - у нее есть Add и Remove операции, и у нее есть проверка Contains - созданные нами обороты речи, которые могут быть использованны нами в коде. Он лучше изолирован, чем простая передача map[string]interface{} и надеясь что значение "набор имен хостов" соблюдается при доступе к map. Это решение более гибкое и явное, чем скажем:

func SetContains(s map[string]interface{}, hostname string) bool {
	_, found := s[hostname]
	return found
}

func main() {
	s := make(map[string]interface{})
	if SetContains(s, hostname) {
        // do stuff
	}
}

Поэкспериментируем чуток с созданием новых типов, частично для различных типов срезов и мапов, и даже для каналов. Какие обороты вы можете создать чтобы заставить работать эти типы проще и понятнее?

Высокоуровневые функции.

Go включает некоторые идеи из функционального программирования которое бесценно для создания выразительных и декларативных API. Go предлагает возможность присваивать функции переменных, для передачи функций как аргумент в другую функцию и для создания анонимных функций и закрытий. Используя высокоуровневые функции которые создают, изменяют, или строят поведение других функций, вы можете легко объединять кусочки логики и функциональности во что-то более сложное целое используя несколько выражений, чаще чем повторение или создание клубка условной логики.

Давайте построим наш пример выше по-новому. Добавим метод в HostList который принимает функцию как входной параметр и возвращает новый HostList:

func (l HostList) Select(f func(Host) bool) HostList {
	result := make(HostList, 0, len(l))
	for _, h := range l {
		if f(h) {
			result = append(result, h)
		}
	}
	return result
}

Этот метод HostList имеет эффективность создания нового HostList для которого предоставленное условие (func f) верно. Создадим простое выражение функции для вставки в f:

// import “strings” 
func IsDotOrg(h Host) bool {
	return strings.HasSuffix(string(h), ".org")
}

И используем его в наше новом методе HostList

myHosts := HostList{"golang.org", "google.com", "gopheracademy.org"}
fmt.Printf("%v\n", myHosts.Select(IsDotOrg))

Вывод будет таким:

[golang.org gopheracademy.org]

Select возвращает только те элементы myHosts для которых переданная функция IsDotOrg будет возвращать true, то есть для тех имен у которых есть ".org".

func(Host) bool немного кривовата как параметричечкий тип и создает подпись метода Select сложно для чтения, поэтому давайте используем наш трюк для типов, чтобы сделать его аккуратным.

type HostFilter func(Host) bool

Он делает Select более читаемым:

func (l HostList) Select(f HostFilter) HostList {
        //...
}

и добавляет выгоду с которой мы можем объявить некоторые методы HostFilter:

func (f HostFilter) Or(g HostFilter) HostFilter {
    return func(h Host) bool {
        return f(h) || g(h)
    }
}

func (f HostFilter) And(g HostFilter) HostFilter {
    return func(h Host) bool {
        return f(h) && g(h)
    }
}

Если мы хотим объявить функцию которая может использовать HostFilter метода, к сожалению нам нжуно пойти другим путём, чтобы это сделать. Чтобы функция имела верный приемни HostFilter метода, недостаточно совпадать описанию HostFilter, нам нужно объявить функцию как HostFilter явно.

var IsDotOrg HostFilter = func(h Host) bool {
	return strings.HasSuffix(string(h), ".org")
}

Но теперь стало ясно что мы начали делать хорошо для нашей угрозы злоупотребления синтаксисом Go. Объявление функции с помощью передачи анонимной функции в переменную дает неясное ощущение. Отметим что это не требование использовать высокоуровневую функцию или преимущество типа для подписи функции - любоая func(Host) bool может быть назначения переменной HostFilter или параметру. Это безрассудное объявление нужно только чтобы можно было использовать функции такие как IsDotOrg как приемник HostFilter метода

Однако выгода, заключается в том, что использование методов в HostFilter функции позволяет получить нам интересный синтаксис:

var HasGo HostFilter = func (h Host) bool {
    return strings.Contains(string(h), "go")
}

var IsAcademic HostFilter = func(h Host) bool {
    return strings.Contains(string(h), "academy")
}

func main() {
    myHosts := HostList{"golang.org", "google.com", "gopheracademy.org"}
    goHosts := myHosts.Select(IsDotOrg.Or(HasGo))
    academies := myHosts.Select(IsDotOrg.And(IsAcademic))

    fmt.Printf("Go sites: %v\n", goHosts)
    fmt.Printf("Academies: %v\n", academies)
}

Запустим:

Go sites: [golang.org google.com gopheracademy.org]
Academies: [gopheracademy.org]

Мы можем увидеть язык приобретает форму выражений типа myHosts.Select(IsDotOrg.Or(HasGo)). Он читается как английский, что-то подобное вы можете услышать на болотах Дагобы. Декларативный синтаксис, начинает появляться - выражения говорять больше о желаемом результате(“select the elements of myHosts that are .orgs or contain ‘Go’) нежели шаги которые требуется чтобы до этого добраться. Мы использовали высокоуровневые функции, Select, And и Or, для построения поведения от этих трех различных кусоков кода в полностью динамичном виде.

Это очень могущественный вид выражения поведения, но все эти методы очереди могут стать запутанными.

// etc.
myHosts.Select(IsDotOrg.Or(HasGo).Or(IsAcademic).Or(WelcomesGophers).And(UsesSSL)

Поэтому возмож мы должны очитсть вещи используя вариативные метод:

var HostFilter Or = func (clauses ...HostFilter) HostFilter {
    var f HostFilter = nil
    for _, c := range clauses {
        f = f.Or(c)
    }
    return f
}

И затем переписать очередь вызовов выше таким образом:

myHosts.Select(Or(IsDotOrg, HasGo, IsAcademic, WelcomesGophers).And(UsesSSL))
Предупредение: эти функциональные сущности самые опасные свойства Go - все нечитаемые Go

Another warning: these functional-style constructs are some of the most dangerously powerful features of Go - all of the truly unreadable Go I’ve ever read and most of the truly unreadable Go I’ve ever written got to be that way by abusing these features, creating anonymous functions and passing them through layer after layer of indirection.

Nevertheless, the dynamism of functional-style programming is invaluable when building our own language inside of Go. Higher-order functions, functions that operate on other functions and return whole new functions, afford us the ability to compose or parameterize behaviors. The purpose of creating a DSL is to simplify the solutions to a class of problems by exposing to the DSL’s user a few concepts useful for solving those problems and empowering them configure and combine those concepts in meaningful ways. Composable dynamic behaviors created through higher-order programming are one way deliver that functionality.

Пример по серьёзнее

We’ll build on our work on hostnames to make something a little closer to what we might use in a real application. Importing the net/http package, let’s create another type identity:

1

type RequestFilter func(*http.Request) bool

We can use a RequestFilter in a simple HTTP server to evaluate whether a given http.Request satisfies a particular condition, as we did with HostFilter above. We can use those conditions to determine whether to handle or reject the request.

We’ll shift from working with hostnames as above to working with ranges of IP addresses. We’ll use CIDR blocks, e.g. "192.168.0.0/16", which identifies a range of IPs from 192.168.0.0 through 192.168.255.255. We’ll create a RequestFilter that filters requests based on IP.

From the net package, we’ll use the ParseCIDR function to parse the CIDRs, and the ParseIP function to parse IP addresses from incoming requests. One of the return values from ParseCIDR is an IPNet which conveniently has a Contains method that will do the work of telling us whether the incoming IP matches the range in our CIDR block.

So let’s also import the net package and write a RequestFilter that takes a variadic input of CIDR blocks in string form:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18

func CIDR(cidrs ...string) RequestFilter { nets := make([]*net.IPNet, len(cidrs)) for i, cidr := range cidrs { // TODO: handle err _, nets[i], _ = net.ParseCIDR(cidr) } return func(r *http.Request) bool { // TODO: handle err host, _, _ := net.SplitHostPort(r.RemoteAddr) ip := net.ParseIP(host) for _, net := range nets { if net.Contains(ip) { return true } } return false } }

Note that the net/http package already contains a type for HTTP handlers, HandlerFunc:

1

type HandlerFunc func(ResponseWriter, *Request)

and we’ll be using higher-order functions and our RequestFilters to modify http.HandlerFuncs, so let’s declare a type for functions that operate on http.HandlerFuncs:

1

type Middleware func(http.HandlerFunc) http.HandlerFunc

and let’s make some functions to build Middleware that uses the RequestFilter:

1 2 3 4 5 6 7 8 9 10 11 12

func Allow(f RequestFilter) Middleware { return func(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if f(r) { h(w, r) } else { // TODO w.WriteHeader(http.StatusForbidden) } } } }

So now, for example, you could modify an HTTP handler MyHandler to only accept requests from 127.0.0.1 with something like:

1

filteredHandler := Allow(CIDR("127.0.0.1/32"))(MyHandler)

Let’s try it by running a simple server:

1 2 3 4 5 6 7 8

func hello(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hello\n") }

func main() { http.HandleFunc("/hello", Allow(CIDR("127.0.0.1/32")(hello)) log.Fatal(http.ListenAndServe(":1217", nil)) }

If you hit your new endpoint from your local machine at http://0.0.0.0:1217/hello, you should see “Hello” in response; if you hit it from another IP address, you should see a 403 Forbidden error.

For fun, let’s add another kind of RequestFilter that implements a really naive authentication mechanism:

1 2 3 4 5

func PasswordHeader(password string) RequestFilter { return func(r *http.Request) bool { return r.Header.Get("X-Password") == password } }

and one based on HTTP method:

1 2 3 4 5 6 7 8 9 10

func Method(methods ...string) RequestFilter { return func(r *http.Request) bool { for _, m := range methods { if r.Method == m { return true } } return false } }

and a Middleware that performs some simple logging:

1 2 3 4 5 6

func Logging(f http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { fmt.Printf("[%v] - %s %s\n", time.Now(), r.Method, r.RequestURI) f(w, r) } }

which we can try with an update to our server:

1 2 3 4

func main() { http.HandleFunc("/hello", Logging(Allow(CIDR("127.0.0.1/32")(hello))) log.Fatal(http.ListenAndServe(":1217", nil)) }

Run this and visit http://localhost:1217/hello a few times in your browser and in the console where the server is running you should see:

1 2 3

[2016-12-14 07:42:12.022266374 -0500 EST] - GET /hello [2016-12-14 07:42:14.537985456 -0500 EST] - GET /hello [2016-12-14 07:42:24.220089221 -0500 EST] - GET /hello

This syntax is fairly declarative as is, but the method chaining can get a little awkward. Methods have to be chained in the right order to behave correctly, and the result can be difficult to read.

We can use a struct to further flesh out our DSL and give our users an even cleaner way to declare their middleware configuration:

1 2 3 4 5 6 7

type Filters []RequestFilters type Stack []Middleware type Endpoint struct { Handler http.HandlerFunc Allow Filters Middleware Stack }

then we could express the endpoint above with the same restrictions as:

1 2 3 4 5 6 7 8 9

var MyEndpoint = Endpoint{ Handler: hello, Allow: Filters{ CIDR("127.0.0.1/32"), }, Middleware: Stack{ Logging, }, }

Which is much easier to write, read, and modify. We just need to add a few methods to our struct and type identities turn this declarative description into a usable http.HandlerFunc:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31

// Combine creates a RequestFilter that is the conjunction // of all the RequestFilters in f. func (f Filters) Combine() RequestFilter { return func(r *http.Request) bool { for _, filter := range f { if !filter(r) { return false } } return true } }

// Apply returns an http.Handlerfunc that has had all of the // Middleware functions in s, if any, to f. func (s Stack) Apply(f http.HandlerFunc) http.HandlerFunc { g := f for _, middleware := range s { g = middleware(g) } return g }

// Builds the endpoint described by e, by applying // access restrictions and other middleware. func (e Endpoint) Build() http.HandlerFunc { allowFilter := e.Allow.Combine() restricted := Allow(allowFilter)(e.Handler)

return e.Middleware.Apply(restricted)

}

and, finally, modify the server to use the endpoint built this way:

1 2 3 4

func main() { http.HandleFunc("/hello", mw.MyEndpoint.Build()) log.Fatal(http.ListenAndServe(":1217", nil)) }

To see the benefit of this mini-DSL we’ve created, let’s add one more kind of middleware:

1 2 3 4 5 6 7 8

func SetHeader(key, value string) Middleware { return func(f http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *RequestFilter) { w.Header().Set(key, value) f(w, r) } } }

And then add it, along with another RequestFilter, to our endpoint:

1 2 3 4 5 6 7 8 9 10 11 12

var MyEndpoint = Endpoint{ Handler: hello, Allow: Filters{ CIDR("127.0.0.1/32"), PasswordHeader("opensesame"), // added Method("GET"), // added }, Middleware: Stack{ Logging, SetHeader("X-Foo", "Bar"), // added }, }

We’ve added significantly to the complexity of MyEndpoint without adding much complexity to its declaration.

This is a useful DSL for building single HTTP endpoints, but frequently we’ll want more than just one on a service. We’ll add one last element to our demo DSL, a way to create several routes and their endpoints at once:

1 2 3 4 5 6 7 8 9 10

type Routes map[string]Endpoint

func (r Routes) Serve(addr string) error { mux := http.NewServeMux() for pattern, endpoint := range r { mux.Handle(pattern, endpoint.Build()) }

return http.ListenAndServe(addr, mux)

}

and then our service becomes:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28

func main() { routes := Routes{ "/hello": { Handler: hello, Middleware: Stack{ Logging, }, }, "/private": { Handler: hello, Allow: Filters{ CIDR("127.0.0.1/32"), PasswordHeader("opensesame"), }, Middleware: Stack{ Logging, }, }, "/test": { Handler: hello, Middleware: Stack{ Logging, SetHeader("X-Foo", "Bar"), }, }, } log.Fatal(routes.Serve(":1217")) }

Note that Go automatically infers the type of the Endpoint struct literals in the Routes map, saving us even more typing and clutter.

This HTTP middleware DSL shows how much can be accomplished in a relatively small amount of Go, but it’s a toy example. Here are some ideas for exercises to extend it and to make the DSL even more powerful:

Implement additional RequestFilters, like a rate-limiter, perhaps using golang.org/x/time/rate or juju/ratelimit, or a more robust authentication mechanism
Implement another Middleware
Modify the Endpoint struct to include a Deny field of type Filters, that rejects the request if any of its RequestFilters is true
Each of the endpoints in the final sample included Logging in its middleware; add to the DSL a facility to apply a set of common restrictions or middleware to all of the endpoints.
Create a way for this middleware stack to create a context.Context and to work with handlers that accept them.

To recap, we used type identities to create abstractions over simple collection types and functions of particular signatures, and we took advantage of Go syntax features like variadic functions and inferred types to write a smooth, uncluttered syntax. The heavy lifting in creating our DSL was performed by higher-order functions that let us create parameterized behaviors that could be combined and configured at runtime. We employed a few dangerous coding practices to do it, but as long as we apply them only when reducing complexity for end-users is the right tradeoff, we can all sleep at night.

The Go you get out of the box is detail-oriented, minimalistic, and can become verbose. Go gives you the tools, however, to build up your own abstractions- your own high-level language- to write code that is as pithy, elegant, and expressive as any you’ll find in a dynamic or purely functional language, but that still gives us access to all of the features we love about Go.