Add web and update deps

This commit is contained in:
Asim Aslam
2019-06-03 19:30:43 +01:00
parent 49f669df66
commit d2857a7b16
15 changed files with 1234 additions and 76 deletions

114
web/README.md Normal file
View File

@@ -0,0 +1,114 @@
# Go Web [![GoDoc](https://godoc.org/github.com/micro/go-micro/web?status.svg)](https://godoc.org/github.com/micro/go-micro/web) [![Travis CI](https://travis-ci.org/micro/go-micro/web.svg?branch=master)](https://travis-ci.org/micro/go-micro/web) [![Go Report Card](https://goreportcard.com/badge/micro/go-micro/web)](https://goreportcard.com/report/github.com/micro/go-micro/web)
**Go Web** is a framework for micro service web development.
## Overview
Go Web provides a tiny HTTP web server library which leverages [go-micro](https://github.com/micro/go-micro) to create
micro web services as first class citizens in a microservice world. It wraps go-micro to give you service discovery,
heartbeating and the ability to create web apps as microservices.
## Features
- **Service Discovery** - Services are automatically registered in service discovery on startup. Go Web includes
a http.Client with pre-initialised roundtripper which makes use of service discovery so you can use service names.
- **Heartbeating** - Go Web apps will periodically heartbeat with service discovery to provide liveness updates.
In the event a service fails it will be removed from the registry after a pre-defined expiry time.
- **Custom Handlers** - Specify your own http router for handling requests. This allows you to maintain full
control over how you want to route to internal handlers.
- **Static Serving** - Go Web automatically detects a local static `html` dir and serves files if no route handler
is specified. A quick solution for those who want to write JS web apps as microservices.
## Getting Started
- [Dependencies](#dependencies)
- [Usage](#usage)
- [Set Handler](#set-handler)
- [Call Service](#call-service)
- [Static Files](#static-files)
## Dependencies
Go Web makes use of Go Micro which means it needs service discovery
See the [go-micro](https://github.com/micro/go-micro#service-discovery) for install instructions
For a quick start use consul
```
# install
brew install consul
# run
consul agent -dev
```
## Usage
```go
service := web.NewService(
web.Name("example.com"),
)
service.HandleFunc("/foo", fooHandler)
if err := service.Init(); err != nil {
log.Fatal(err)
}
if err := service.Run(); err != nil {
log.Fatal(err)
}
```
## Set Handler
You might have a preference for a HTTP handler, so use something else. This loses the ability to register endpoints in discovery
but we'll fix that soon.
```go
import "github.com/gorilla/mux"
r := mux.NewRouter()
r.HandleFunc("/", indexHandler)
r.HandleFunc("/objects/{object}", objectHandler)
service := web.NewService(
web.Handler(r)
)
```
## Call Service
Go-web includes a http.Client with a custom http.RoundTripper that uses service discovery
```go
c := service.Client()
rsp, err := c.Get("http://example.com/foo")
```
This will lookup service discovery for the service `example.com` and route to one of the available nodes.
## Static Files
Go web was always meant as a way to register web apps where the majority of the code would be written in JS. To enable that by default, if no handler is registered on "/" and we find a local "html" directory then static files will be served.
You will see a log output like so.
```
2019/05/12 14:55:47 Enabling static file serving from /tmp/foo/html
```
If you want to set this path manually use the StaticDir option. If a relative path is specified we will use os.Getwd() and prefix this.
```
service := web.NewService(
web.Name("example.com"),
web.StaticDir("/tmp/example.com/html"),
)
```

7
web/examples/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Examples
Name | Description
--- | ---
[Message](https://github.com/micro/message-web) | A simple text based messaging web app
[Geo](https://github.com/micro/geo-web) | A geo location map demo

View File

@@ -0,0 +1,29 @@
package main
import (
"fmt"
"log"
"net/http"
"github.com/micro/go-micro/web"
)
func helloWorldHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<html><body><h1>Hello World</h1></body></html>`)
}
func main() {
service := web.NewService(
web.Name("helloworld"),
)
service.HandleFunc("/", helloWorldHandler)
if err := service.Init(); err != nil {
log.Fatal(err)
}
if err := service.Run(); err != nil {
log.Fatal(err)
}
}

217
web/options.go Normal file
View File

@@ -0,0 +1,217 @@
package web
import (
"context"
"crypto/tls"
"net/http"
"time"
"github.com/micro/cli"
"github.com/micro/go-micro"
"github.com/micro/go-micro/registry"
)
type Options struct {
Name string
Version string
Id string
Metadata map[string]string
Address string
Advertise string
Action func(*cli.Context)
Flags []cli.Flag
RegisterTTL time.Duration
RegisterInterval time.Duration
Server *http.Server
Handler http.Handler
// Alternative Options
Context context.Context
Registry registry.Registry
Service micro.Service
Secure bool
TLSConfig *tls.Config
BeforeStart []func() error
BeforeStop []func() error
AfterStart []func() error
AfterStop []func() error
// Static directory
StaticDir string
}
func newOptions(opts ...Option) Options {
opt := Options{
Name: DefaultName,
Version: DefaultVersion,
Id: DefaultId,
Address: DefaultAddress,
RegisterTTL: DefaultRegisterTTL,
RegisterInterval: DefaultRegisterInterval,
StaticDir: DefaultStaticDir,
Service: micro.NewService(),
Context: context.TODO(),
}
for _, o := range opts {
o(&opt)
}
return opt
}
// Server name
func Name(n string) Option {
return func(o *Options) {
o.Name = n
}
}
// Unique server id
func Id(id string) Option {
return func(o *Options) {
o.Id = id
}
}
// Version of the service
func Version(v string) Option {
return func(o *Options) {
o.Version = v
}
}
// Metadata associated with the service
func Metadata(md map[string]string) Option {
return func(o *Options) {
o.Metadata = md
}
}
// Address to bind to - host:port
func Address(a string) Option {
return func(o *Options) {
o.Address = a
}
}
// The address to advertise for discovery - host:port
func Advertise(a string) Option {
return func(o *Options) {
o.Advertise = a
}
}
// Context specifies a context for the service.
// Can be used to signal shutdown of the service.
// Can be used for extra option values.
func Context(ctx context.Context) Option {
return func(o *Options) {
o.Context = ctx
}
}
func Registry(r registry.Registry) Option {
return func(o *Options) {
o.Registry = r
}
}
func RegisterTTL(t time.Duration) Option {
return func(o *Options) {
o.RegisterTTL = t
}
}
func RegisterInterval(t time.Duration) Option {
return func(o *Options) {
o.RegisterInterval = t
}
}
func Handler(h http.Handler) Option {
return func(o *Options) {
o.Handler = h
}
}
func Server(srv *http.Server) Option {
return func(o *Options) {
o.Server = srv
}
}
// MicroService sets the micro.Service used internally
func MicroService(s micro.Service) Option {
return func(o *Options) {
o.Service = s
}
}
// Flags sets the command flags.
func Flags(flags ...cli.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, flags...)
}
}
// Action sets the command action.
func Action(a func(*cli.Context)) Option {
return func(o *Options) {
o.Action = a
}
}
// BeforeStart is executed before the server starts.
func BeforeStart(fn func() error) Option {
return func(o *Options) {
o.BeforeStart = append(o.BeforeStart, fn)
}
}
// BeforeStop is executed before the server stops.
func BeforeStop(fn func() error) Option {
return func(o *Options) {
o.BeforeStop = append(o.BeforeStop, fn)
}
}
// AfterStart is executed after server start.
func AfterStart(fn func() error) Option {
return func(o *Options) {
o.AfterStart = append(o.AfterStart, fn)
}
}
// AfterStop is executed after server stop.
func AfterStop(fn func() error) Option {
return func(o *Options) {
o.AfterStop = append(o.AfterStop, fn)
}
}
// Secure Use secure communication. If TLSConfig is not specified we use InsecureSkipVerify and generate a self signed cert
func Secure(b bool) Option {
return func(o *Options) {
o.Secure = b
}
}
// TLSConfig to be used for the transport.
func TLSConfig(t *tls.Config) Option {
return func(o *Options) {
o.TLSConfig = t
}
}
// StaticDir sets the static file directory. This defaults to ./html
func StaticDir(d string) Option {
return func(o *Options) {
o.StaticDir = d
}
}

445
web/service.go Normal file
View File

@@ -0,0 +1,445 @@
package web
import (
"crypto/tls"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/micro/cli"
"github.com/micro/go-micro"
"github.com/micro/go-micro/registry"
maddr "github.com/micro/go-micro/util/addr"
mhttp "github.com/micro/go-micro/util/http"
"github.com/micro/go-micro/util/log"
mnet "github.com/micro/go-micro/util/net"
mls "github.com/micro/go-micro/util/tls"
)
type service struct {
opts Options
mux *http.ServeMux
srv *registry.Service
sync.Mutex
running bool
static bool
exit chan chan error
}
func newService(opts ...Option) Service {
options := newOptions(opts...)
s := &service{
opts: options,
mux: http.NewServeMux(),
static: true,
}
s.srv = s.genSrv()
return s
}
func (s *service) genSrv() *registry.Service {
// default host:port
parts := strings.Split(s.opts.Address, ":")
host := strings.Join(parts[:len(parts)-1], ":")
port, _ := strconv.Atoi(parts[len(parts)-1])
// check the advertise address first
// if it exists then use it, otherwise
// use the address
if len(s.opts.Advertise) > 0 {
parts = strings.Split(s.opts.Advertise, ":")
// we have host:port
if len(parts) > 1 {
// set the host
host = strings.Join(parts[:len(parts)-1], ":")
// get the port
if aport, _ := strconv.Atoi(parts[len(parts)-1]); aport > 0 {
port = aport
}
} else {
host = parts[0]
}
}
addr, err := maddr.Extract(host)
if err != nil {
// best effort localhost
addr = "127.0.0.1"
}
return &registry.Service{
Name: s.opts.Name,
Version: s.opts.Version,
Nodes: []*registry.Node{&registry.Node{
Id: s.opts.Id,
Address: addr,
Port: port,
Metadata: s.opts.Metadata,
}},
}
}
func (s *service) run(exit chan bool) {
if s.opts.RegisterInterval <= time.Duration(0) {
return
}
t := time.NewTicker(s.opts.RegisterInterval)
for {
select {
case <-t.C:
s.register()
case <-exit:
t.Stop()
return
}
}
}
func (s *service) register() error {
if s.srv == nil {
return nil
}
// default to service registry
r := s.opts.Service.Client().Options().Registry
// switch to option if specified
if s.opts.Registry != nil {
r = s.opts.Registry
}
return r.Register(s.srv, registry.RegisterTTL(s.opts.RegisterTTL))
}
func (s *service) deregister() error {
if s.srv == nil {
return nil
}
// default to service registry
r := s.opts.Service.Client().Options().Registry
// switch to option if specified
if s.opts.Registry != nil {
r = s.opts.Registry
}
return r.Deregister(s.srv)
}
func (s *service) start() error {
s.Lock()
defer s.Unlock()
if s.running {
return nil
}
l, err := s.listen("tcp", s.opts.Address)
if err != nil {
return err
}
s.opts.Address = l.Addr().String()
srv := s.genSrv()
srv.Endpoints = s.srv.Endpoints
s.srv = srv
var h http.Handler
if s.opts.Handler != nil {
h = s.opts.Handler
} else {
h = s.mux
var r sync.Once
// register the html dir
r.Do(func() {
// static dir
static := s.opts.StaticDir
if s.opts.StaticDir[0] != '/' {
dir, _ := os.Getwd()
static = filepath.Join(dir, static)
}
// set static if no / handler is registered
if s.static {
_, err := os.Stat(static)
if err == nil {
log.Logf("Enabling static file serving from %s", static)
s.mux.Handle("/", http.FileServer(http.Dir(static)))
}
}
})
}
for _, fn := range s.opts.BeforeStart {
if err := fn(); err != nil {
return err
}
}
var httpSrv *http.Server
if s.opts.Server != nil {
httpSrv = s.opts.Server
} else {
httpSrv = &http.Server{}
}
httpSrv.Handler = h
go httpSrv.Serve(l)
for _, fn := range s.opts.AfterStart {
if err := fn(); err != nil {
return err
}
}
s.exit = make(chan chan error, 1)
s.running = true
go func() {
ch := <-s.exit
ch <- l.Close()
}()
log.Logf("Listening on %v\n", l.Addr().String())
return nil
}
func (s *service) stop() error {
s.Lock()
defer s.Unlock()
if !s.running {
return nil
}
for _, fn := range s.opts.BeforeStop {
if err := fn(); err != nil {
return err
}
}
ch := make(chan error, 1)
s.exit <- ch
s.running = false
log.Log("Stopping")
for _, fn := range s.opts.AfterStop {
if err := fn(); err != nil {
if chErr := <-ch; chErr != nil {
return chErr
}
return err
}
}
return <-ch
}
func (s *service) Client() *http.Client {
rt := mhttp.NewRoundTripper(
mhttp.WithRegistry(registry.DefaultRegistry),
)
return &http.Client{
Transport: rt,
}
}
func (s *service) Handle(pattern string, handler http.Handler) {
var seen bool
for _, ep := range s.srv.Endpoints {
if ep.Name == pattern {
seen = true
break
}
}
// if its unseen then add an endpoint
if !seen {
s.srv.Endpoints = append(s.srv.Endpoints, &registry.Endpoint{
Name: pattern,
})
}
// disable static serving
if pattern == "/" {
s.Lock()
s.static = false
s.Unlock()
}
// register the handler
s.mux.Handle(pattern, handler)
}
func (s *service) HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) {
var seen bool
for _, ep := range s.srv.Endpoints {
if ep.Name == pattern {
seen = true
break
}
}
if !seen {
s.srv.Endpoints = append(s.srv.Endpoints, &registry.Endpoint{
Name: pattern,
})
}
s.mux.HandleFunc(pattern, handler)
}
func (s *service) Init(opts ...Option) error {
for _, o := range opts {
o(&s.opts)
}
serviceOpts := []micro.Option{}
if len(s.opts.Flags) > 0 {
serviceOpts = append(serviceOpts, micro.Flags(s.opts.Flags...))
}
if s.opts.Registry != nil {
serviceOpts = append(serviceOpts, micro.Registry(s.opts.Registry))
}
serviceOpts = append(serviceOpts, micro.Action(func(ctx *cli.Context) {
if ttl := ctx.Int("register_ttl"); ttl > 0 {
s.opts.RegisterTTL = time.Duration(ttl) * time.Second
}
if interval := ctx.Int("register_interval"); interval > 0 {
s.opts.RegisterInterval = time.Duration(interval) * time.Second
}
if name := ctx.String("server_name"); len(name) > 0 {
s.opts.Name = name
}
if ver := ctx.String("server_version"); len(ver) > 0 {
s.opts.Version = ver
}
if id := ctx.String("server_id"); len(id) > 0 {
s.opts.Id = id
}
if addr := ctx.String("server_address"); len(addr) > 0 {
s.opts.Address = addr
}
if adv := ctx.String("server_advertise"); len(adv) > 0 {
s.opts.Advertise = adv
}
if s.opts.Action != nil {
s.opts.Action(ctx)
}
}))
s.opts.Service.Init(serviceOpts...)
srv := s.genSrv()
srv.Endpoints = s.srv.Endpoints
s.srv = srv
return nil
}
func (s *service) Run() error {
if err := s.start(); err != nil {
return err
}
if err := s.register(); err != nil {
return err
}
// start reg loop
ex := make(chan bool)
go s.run(ex)
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT, syscall.SIGKILL)
select {
// wait on kill signal
case sig := <-ch:
log.Logf("Received signal %s\n", sig)
// wait on context cancel
case <-s.opts.Context.Done():
log.Logf("Received context shutdown")
}
// exit reg loop
close(ex)
if err := s.deregister(); err != nil {
return err
}
return s.stop()
}
// Options returns the options for the given service
func (s *service) Options() Options {
return s.opts
}
func (s *service) listen(network, addr string) (net.Listener, error) {
var l net.Listener
var err error
// TODO: support use of listen options
if s.opts.Secure || s.opts.TLSConfig != nil {
config := s.opts.TLSConfig
fn := func(addr string) (net.Listener, error) {
if config == nil {
hosts := []string{addr}
// check if its a valid host:port
if host, _, err := net.SplitHostPort(addr); err == nil {
if len(host) == 0 {
hosts = maddr.IPs()
} else {
hosts = []string{host}
}
}
// generate a certificate
cert, err := mls.Certificate(hosts...)
if err != nil {
return nil, err
}
config = &tls.Config{Certificates: []tls.Certificate{cert}}
}
return tls.Listen(network, addr, config)
}
l, err = mnet.Listen(addr, fn)
} else {
fn := func(addr string) (net.Listener, error) {
return net.Listen(network, addr)
}
l, err = mnet.Listen(addr, fn)
}
if err != nil {
return nil, err
}
return l, nil
}

260
web/service_test.go Normal file
View File

@@ -0,0 +1,260 @@
package web
import (
"crypto/tls"
"fmt"
"io/ioutil"
"net/http"
"os"
"os/signal"
"syscall"
"testing"
"time"
"github.com/micro/go-micro/registry"
"github.com/micro/go-micro/registry/memory"
)
func TestService(t *testing.T) {
var (
beforeStartCalled bool
afterStartCalled bool
beforeStopCalled bool
afterStopCalled bool
str = `<html><body><h1>Hello World</h1></body></html>`
fn = func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, str) }
reg = memory.NewRegistry()
)
beforeStart := func() error {
beforeStartCalled = true
return nil
}
afterStart := func() error {
afterStartCalled = true
return nil
}
beforeStop := func() error {
beforeStopCalled = true
return nil
}
afterStop := func() error {
afterStopCalled = true
return nil
}
service := NewService(
Name("go.micro.web.test"),
Registry(reg),
BeforeStart(beforeStart),
AfterStart(afterStart),
BeforeStop(beforeStop),
AfterStop(afterStop),
)
service.HandleFunc("/", fn)
go func() {
if err := service.Run(); err != nil {
t.Fatal(err)
}
}()
var s []*registry.Service
eventually(func() bool {
var err error
s, err = reg.GetService("go.micro.web.test")
return err == nil
}, t.Fatal)
if have, want := len(s), 1; have != want {
t.Fatalf("Expected %d but got %d services", want, have)
}
rsp, err := http.Get(fmt.Sprintf("http://%s:%d", s[0].Nodes[0].Address, s[0].Nodes[0].Port))
if err != nil {
t.Fatal(err)
}
defer rsp.Body.Close()
b, err := ioutil.ReadAll(rsp.Body)
if err != nil {
t.Fatal(err)
}
if string(b) != str {
t.Errorf("Expected %s got %s", str, string(b))
}
callbackTests := []struct {
subject string
have interface{}
}{
{"beforeStartCalled", beforeStartCalled},
{"afterStartCalled", afterStartCalled},
}
for _, tt := range callbackTests {
if tt.have != true {
t.Errorf("unexpected %s: want true, have false", tt.subject)
}
}
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM)
syscall.Kill(syscall.Getpid(), syscall.SIGTERM)
<-ch
eventually(func() bool {
_, err := reg.GetService("go.micro.web.test")
return err == registry.ErrNotFound
}, t.Error)
callbackTests = []struct {
subject string
have interface{}
}{
{"beforeStopCalled", beforeStopCalled},
{"afterStopCalled", afterStopCalled},
}
for _, tt := range callbackTests {
if tt.have != true {
t.Errorf("unexpected %s: want true, have false", tt.subject)
}
}
}
func TestOptions(t *testing.T) {
var (
name = "service-name"
id = "service-id"
version = "service-version"
address = "service-addr"
advertise = "service-adv"
reg = memory.NewRegistry()
registerTTL = 123 * time.Second
registerInterval = 456 * time.Second
handler = http.NewServeMux()
metadata = map[string]string{"key": "val"}
secure = true
)
service := NewService(
Name(name),
Id(id),
Version(version),
Address(address),
Advertise(advertise),
Registry(reg),
RegisterTTL(registerTTL),
RegisterInterval(registerInterval),
Handler(handler),
Metadata(metadata),
Secure(secure),
)
opts := service.Options()
tests := []struct {
subject string
want interface{}
have interface{}
}{
{"name", name, opts.Name},
{"version", version, opts.Version},
{"id", id, opts.Id},
{"address", address, opts.Address},
{"advertise", advertise, opts.Advertise},
{"registry", reg, opts.Registry},
{"registerTTL", registerTTL, opts.RegisterTTL},
{"registerInterval", registerInterval, opts.RegisterInterval},
{"handler", handler, opts.Handler},
{"metadata", metadata["key"], opts.Metadata["key"]},
{"secure", secure, opts.Secure},
}
for _, tc := range tests {
if tc.want != tc.have {
t.Errorf("unexpected %s: want %v, have %v", tc.subject, tc.want, tc.have)
}
}
}
func eventually(pass func() bool, fail func(...interface{})) {
tick := time.NewTicker(10 * time.Millisecond)
defer tick.Stop()
timeout := time.After(time.Second)
for {
select {
case <-timeout:
fail("timed out")
return
case <-tick.C:
if pass() {
return
}
}
}
}
func TestTLS(t *testing.T) {
var (
str = `<html><body><h1>Hello World</h1></body></html>`
fn = func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, str) }
secure = true
reg = memory.NewRegistry()
)
service := NewService(
Name("go.micro.web.test"),
Secure(secure),
Registry(reg),
)
service.HandleFunc("/", fn)
go func() {
if err := service.Run(); err != nil {
t.Fatal(err)
}
}()
var s []*registry.Service
eventually(func() bool {
var err error
s, err = reg.GetService("go.micro.web.test")
return err == nil
}, t.Fatal)
if have, want := len(s), 1; have != want {
t.Fatalf("Expected %d but got %d services", want, have)
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client := &http.Client{Transport: tr}
rsp, err := client.Get(fmt.Sprintf("https://%s:%d", s[0].Nodes[0].Address, s[0].Nodes[0].Port))
if err != nil {
t.Fatal(err)
}
defer rsp.Body.Close()
b, err := ioutil.ReadAll(rsp.Body)
if err != nil {
t.Fatal(err)
}
if string(b) != str {
t.Errorf("Expected %s got %s", str, string(b))
}
}

41
web/web.go Normal file
View File

@@ -0,0 +1,41 @@
// Package web provides web based micro services
package web
import (
"net/http"
"time"
"github.com/google/uuid"
)
// Service is a web service with service discovery built in
type Service interface {
Client() *http.Client
Init(opts ...Option) error
Options() Options
Handle(pattern string, handler http.Handler)
HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request))
Run() error
}
type Option func(o *Options)
var (
// For serving
DefaultName = "go-web"
DefaultVersion = "latest"
DefaultId = uuid.New().String()
DefaultAddress = ":0"
// for registration
DefaultRegisterTTL = time.Minute
DefaultRegisterInterval = time.Second * 30
// static directory
DefaultStaticDir = "html"
)
// NewService returns a new web.Service
func NewService(opts ...Option) Service {
return newService(opts...)
}