From 8e533c849e5b0053a1949dcbf98f8948a7501499 Mon Sep 17 00:00:00 2001 From: Sebastian Harl Date: Wed, 4 May 2016 09:48:01 +0200 Subject: [PATCH] Add an initial version of sample code for my Go talk. This includes a sample RPC server and client along with a sample HTTP server based on the examples from the talk. In addition, there are sample integration tests. --- LICENSE | 24 +++ README | 54 ++++++ grpc/client/main.go | 63 +++++++ grpc/integration/backend/backend_test.go | 225 ++++++++++++++++++++++ grpc/integration/ui/ui_test.go | 189 +++++++++++++++++++ grpc/proto/backend/backend.pb.go | 143 ++++++++++++++ grpc/proto/backend/backend.proto | 43 +++++ grpc/server/main.go | 113 +++++++++++ grpc/ui/main.go | 228 +++++++++++++++++++++++ 9 files changed, 1082 insertions(+) create mode 100644 LICENSE create mode 100644 README create mode 100644 grpc/client/main.go create mode 100644 grpc/integration/backend/backend_test.go create mode 100644 grpc/integration/ui/ui_test.go create mode 100644 grpc/proto/backend/backend.pb.go create mode 100644 grpc/proto/backend/backend.proto create mode 100644 grpc/server/main.go create mode 100644 grpc/ui/main.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cd0f3d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2016 Sebastian 'tokkee' Harl +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/README b/README new file mode 100644 index 0000000..351ecbc --- /dev/null +++ b/README @@ -0,0 +1,54 @@ + tokkee's Go and gRPC talk + =========================== + + Supporting material for a talk about Go and gRPC. + +Setup +----- + + Set up the Go workspace in a directory of your choice (<...>): + + $ export GOPATH=<...> + $ export PATH=$GOPATH/bin:$PATH + + Get the source: + + $ go get -u tokkee.org/go-talk/grpc/... + + Install the program binaries: + + $ go install tokkee.org/go-talk/grpc/server + $ go install tokkee.org/go-talk/grpc/client + $ go install tokkee.org/go-talk/grpc/ui + +Testing +------- + + The programs come with a set of sample integration tests which will spin up + test instances of the respective tools and run queries against them. + + Run all tests: + + $ go test tokkee.org/go-talk/grpc/integration/... + +Rebuilding the generated proto / gRPC code +------------------------------------------ + + Install protoc from https://github.com/google/protobuf/. We need version 3. + + Install the Go gRPC generator plugin: + + $ go get github.com/golang/protobuf/protoc-gen-go + + Rebuild the code: + + $ cd $GOPATH + $ protoc src/tokkee.org/go-talk/grpc/proto/backend/backend.proto \ + --go_out=plugins=grpc:. + +Author +------ + + Sebastian 'tokkee' Harl + + Licensed under the 2-clause BSD license. See LICENSE for details. diff --git a/grpc/client/main.go b/grpc/client/main.go new file mode 100644 index 0000000..8feb1e2 --- /dev/null +++ b/grpc/client/main.go @@ -0,0 +1,63 @@ +// Copyright (C) 2016 Sebastian 'tokkee' Harl +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +// TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +// OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// client implements a simple client for the backend protocol. +package main + +import ( + "flag" + "log" + + "golang.org/x/net/context" + "google.golang.org/grpc" + + pb "tokkee.org/go-talk/grpc/proto/backend" +) + +var ( + server = flag.String("server", "localhost:50051", "server address") +) + +func main() { + ctx := context.Background() + flag.Parse() + + if flag.NArg() != 1 { + log.Fatal("Missing query.") + } + query := flag.Arg(0) + + conn, err := grpc.Dial(*server, grpc.WithInsecure()) + if err != nil { + log.Fatalf("Failed to connect to server at %s: %v", *server, err) + } + defer conn.Close() + + c := pb.NewBackendClient(conn) + res, err := c.Query(ctx, &pb.QueryRequest{Query: query}) + if err != nil { + log.Fatalf("Query failed: %v", err) + } + log.Printf("%s: %d\n", res.Type, res.N) +} diff --git a/grpc/integration/backend/backend_test.go b/grpc/integration/backend/backend_test.go new file mode 100644 index 0000000..7000226 --- /dev/null +++ b/grpc/integration/backend/backend_test.go @@ -0,0 +1,225 @@ +// Copyright (C) 2016 Sebastian 'tokkee' Harl +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +// TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +// OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package backend_test + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/golang/protobuf/proto" + "golang.org/x/net/context" + "google.golang.org/grpc" + + pb "tokkee.org/go-talk/grpc/proto/backend" +) + +var ( + server string + client string + + // TODO: probe for an unused port + port = "50051" +) + +func init() { + gopath := os.Getenv("GOPATH") + if gopath == "" { + panic("GOPATH not set") + } + + server = filepath.Join(gopath, "bin", "server") + client = filepath.Join(gopath, "bin", "client") + + for _, path := range []string{server, client} { + if _, err := os.Stat(path); err != nil { + if !os.IsNotExist(err) { + panic(err) + } + panic(fmt.Sprintf("%s and/or %s not found; run 'go install tokkee.org/go-talk/grpc/server tokkee.org/go-talk/grpc/client'", server, client)) + } + } +} + +// setup sets up a Backend test instance and returns a client connected to it +// and a cleanup function to be called when done. +func setup() (pb.BackendClient, func(), error) { + srv := exec.Command(server, "--listen=:"+port) + if err := srv.Start(); err != nil { + return nil, nil, fmt.Errorf("failed to start server: %v", err) + } + + conn, err := grpc.Dial("localhost:"+port, grpc.WithInsecure()) + if err != nil { + srv.Process.Kill() + return nil, nil, fmt.Errorf("failed to connect to server: %v", err) + } + + return pb.NewBackendClient(conn), func() { + conn.Close() + + if err := srv.Process.Kill(); err != nil { + log.Printf("Failed to kill server process: %v", err) + } + srv.Wait() + }, nil +} + +// TestServer runs sample queries against the backend server using the RPC +// interface and checks the results. +func TestServer(t *testing.T) { + ctx := context.Background() + c, cleanup, err := setup() + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + defer cleanup() + + for _, test := range []struct { + query string + wantErr bool + expected *pb.QueryReply + }{ + { + query: "CounT 123456", + expected: &pb.QueryReply{ + Type: "COUNT", + N: 6, + }, + }, + { + query: "count abc", + expected: &pb.QueryReply{ + Type: "COUNT", + N: 3, + }, + }, + { + query: "count multiple words are supported as well", + expected: &pb.QueryReply{ + Type: "COUNT", + N: 36, + }, + }, + { + query: "RANDOM 7", + expected: &pb.QueryReply{ + Type: "RANDOM", + N: 4, + }, + }, + { + query: "RANDOM 4", + expected: &pb.QueryReply{ + Type: "RANDOM", + N: 0, + }, + }, + { + query: "RANDOM NAN", + wantErr: true, + }, + { + query: "COUNT", + wantErr: true, + }, + { + query: "INVALID COMMAND", + wantErr: true, + }, + } { + req := &pb.QueryRequest{Query: test.query} + res, err := c.Query(ctx, req) + if (err != nil) != test.wantErr || !proto.Equal(res, test.expected) { + e := "" + if test.wantErr { + e = "" + } + t.Errorf("c.Query(%v) = %v, %v; want %v, %s", req, res, err, test.expected, e) + } + } +} + +// TestClient runs sample queries against the backend server using the client +// program and checks the results. +func TestClient(t *testing.T) { + _, cleanup, err := setup() + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + defer cleanup() + + for _, test := range []struct { + query string + wantErr bool + expected string + }{ + { + query: "count 123456", + expected: "COUNT: 6", + }, + { + query: "count abc", + expected: "COUNT: 3", + }, + { + query: "count multiple words are supported as well", + expected: "COUNT: 36", + }, + { + query: "RANDOM 7", + expected: "RANDOM: 4", + }, + { + query: "RANDOM 4", + expected: "RANDOM: 0", + }, + { + query: "RANDOM NAN", + wantErr: true, + }, + { + query: "COUNT", + wantErr: true, + }, + { + query: "INVALID COMMAND", + wantErr: true, + }, + } { + out, err := exec.Command(client, "--server=localhost:"+port, test.query).CombinedOutput() + if (err != nil) != test.wantErr || !strings.HasSuffix(string(out), test.expected+"\n") { + e := "" + if test.wantErr { + e = "" + } + t.Errorf("%s %s returned %q, %v; want %q, %s", client, test.query, string(out), err, test.expected, e) + } + } +} diff --git a/grpc/integration/ui/ui_test.go b/grpc/integration/ui/ui_test.go new file mode 100644 index 0000000..fda93c8 --- /dev/null +++ b/grpc/integration/ui/ui_test.go @@ -0,0 +1,189 @@ +// Copyright (C) 2016 Sebastian 'tokkee' Harl +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +// TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +// OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package ui_test + +import ( + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "google.golang.org/grpc" +) + +var ( + server string + ui string + + // TODO: probe for unused ports + serverPort = "50051" + uiPort = "9999" +) + +func init() { + gopath := os.Getenv("GOPATH") + if gopath == "" { + panic("GOPATH not set") + } + + server = filepath.Join(gopath, "bin", "server") + ui = filepath.Join(gopath, "bin", "ui") + + for _, path := range []string{server, ui} { + if _, err := os.Stat(path); err != nil { + if !os.IsNotExist(err) { + panic(err) + } + panic(fmt.Sprintf("%s and/or %s not found; run 'go install tokkee.org/go-talk/grpc/server tokkee.org/go-talk/grpc/ui'", server, ui)) + } + } +} + +// setup sets up a Backend test instance and UI and returns the UI address and +// a cleanup function to be called when done. +func setup() (string, func(), error) { + srv := exec.Command(server, "--listen=:"+serverPort) + if err := srv.Start(); err != nil { + return "", nil, fmt.Errorf("failed to start server: %v", err) + } + // Wait for the server to be ready. + conn, err := grpc.Dial("localhost:"+serverPort, grpc.WithInsecure()) + if err != nil { + srv.Process.Kill() + return "", nil, fmt.Errorf("failed to connect to server: %v", err) + } + conn.Close() + + u := exec.Command(ui, "--listen=:"+uiPort, "--backend=localhost:"+serverPort) + if err := u.Start(); err != nil { + srv.Process.Kill() + return "", nil, fmt.Errorf("failed to start UI: %v", err) + } + // Wait for the UI to be ready. + for { + if _, err := http.Get("http://localhost:" + uiPort + "/query"); err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + + return "http://localhost:" + uiPort, func() { + if err := u.Process.Kill(); err != nil { + log.Printf("Failed to kill UI process: %v", err) + } + u.Wait() + + if err := srv.Process.Kill(); err != nil { + log.Printf("Failed to kill server process: %v", err) + } + srv.Wait() + }, nil +} + +// TestQuery runs sample queries against the /query endpoint of the UI and +// checks the results. +func TestQuery(t *testing.T) { + addr, cleanup, err := setup() + if err != nil { + t.Fatalf("Setup failed: %v", err) + } + defer cleanup() + + for _, test := range []struct { + query string + status int + expected []string + }{ + { + query: "CounT 123456", + status: 200, + expected: []string{ + "CounT 123456 => COUNT: 6", + }, + }, + { + query: "count abc", + status: 200, + expected: []string{ + "count abc => COUNT: 3", + }, + }, + { + query: "count multiple words are supported as well; RANDOM 7; RANDOM 4", + status: 200, + expected: []string{ + "count multiple words are supported as well => COUNT: 36", + "RANDOM 7 => RANDOM: 4", + "RANDOM 4 => RANDOM: 0", + }, + }, + { + query: "RANDOM NAN", + status: 400, + }, + { + query: "COUNT", + status: 400, + }, + { + query: "INVALID COMMAND", + status: 400, + }, + } { + params := make(url.Values) + params.Add("q", test.query) + res, err := http.PostForm(addr+"/query", params) + if err != nil { + t.Errorf("PostForm(%q, %v) = %v", addr+"/query", params, err) + continue + } + defer res.Body.Close() + + raw, err := ioutil.ReadAll(res.Body) + if err != nil { + t.Errorf("Failed to read response body: %v", err) + continue + } + body := string(raw) + + ok := true + for _, expected := range test.expected { + if !strings.Contains(body, expected) { + ok = false + break + } + } + if res.StatusCode != test.status || !ok { + t.Errorf("POST(%q, %v) = %v:\n%s\nwant status=%d; matches=%v", addr+"/query", params, res, body, test.status, test.expected) + } + } +} diff --git a/grpc/proto/backend/backend.pb.go b/grpc/proto/backend/backend.pb.go new file mode 100644 index 0000000..91609b5 --- /dev/null +++ b/grpc/proto/backend/backend.pb.go @@ -0,0 +1,143 @@ +// Code generated by protoc-gen-go. +// source: src/tokkee.org/go-talk/grpc/proto/backend/backend.proto +// DO NOT EDIT! + +/* +Package backend is a generated protocol buffer package. + +It is generated from these files: + src/tokkee.org/go-talk/grpc/proto/backend/backend.proto + +It has these top-level messages: + QueryRequest + QueryReply +*/ +package backend + +import proto "github.com/golang/protobuf/proto" +import fmt "fmt" +import math "math" + +import ( + context "golang.org/x/net/context" + grpc "google.golang.org/grpc" +) + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +const _ = proto.ProtoPackageIsVersion1 + +type QueryRequest struct { + Query string `protobuf:"bytes,1,opt,name=query" json:"query,omitempty"` +} + +func (m *QueryRequest) Reset() { *m = QueryRequest{} } +func (m *QueryRequest) String() string { return proto.CompactTextString(m) } +func (*QueryRequest) ProtoMessage() {} +func (*QueryRequest) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } + +type QueryReply struct { + Type string `protobuf:"bytes,1,opt,name=type" json:"type,omitempty"` + N int64 `protobuf:"varint,2,opt,name=n" json:"n,omitempty"` +} + +func (m *QueryReply) Reset() { *m = QueryReply{} } +func (m *QueryReply) String() string { return proto.CompactTextString(m) } +func (*QueryReply) ProtoMessage() {} +func (*QueryReply) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{1} } + +func init() { + proto.RegisterType((*QueryRequest)(nil), "backend.QueryRequest") + proto.RegisterType((*QueryReply)(nil), "backend.QueryReply") +} + +// Reference imports to suppress errors if they are not otherwise used. +var _ context.Context +var _ grpc.ClientConn + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +const _ = grpc.SupportPackageIsVersion2 + +// Client API for Backend service + +type BackendClient interface { + Query(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*QueryReply, error) +} + +type backendClient struct { + cc *grpc.ClientConn +} + +func NewBackendClient(cc *grpc.ClientConn) BackendClient { + return &backendClient{cc} +} + +func (c *backendClient) Query(ctx context.Context, in *QueryRequest, opts ...grpc.CallOption) (*QueryReply, error) { + out := new(QueryReply) + err := grpc.Invoke(ctx, "/backend.Backend/Query", in, out, c.cc, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// Server API for Backend service + +type BackendServer interface { + Query(context.Context, *QueryRequest) (*QueryReply, error) +} + +func RegisterBackendServer(s *grpc.Server, srv BackendServer) { + s.RegisterService(&_Backend_serviceDesc, srv) +} + +func _Backend_Query_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(QueryRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BackendServer).Query(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/backend.Backend/Query", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BackendServer).Query(ctx, req.(*QueryRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var _Backend_serviceDesc = grpc.ServiceDesc{ + ServiceName: "backend.Backend", + HandlerType: (*BackendServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Query", + Handler: _Backend_Query_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, +} + +var fileDescriptor0 = []byte{ + // 174 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xe2, 0x32, 0x2f, 0x2e, 0x4a, 0xd6, + 0x2f, 0xc9, 0xcf, 0xce, 0x4e, 0x4d, 0xd5, 0xcb, 0x2f, 0x4a, 0xd7, 0x4f, 0xcf, 0xd7, 0x2d, 0x49, + 0xcc, 0xc9, 0xd6, 0x4f, 0x2f, 0x2a, 0x48, 0xd6, 0x2f, 0x28, 0xca, 0x2f, 0xc9, 0xd7, 0x4f, 0x4a, + 0x4c, 0xce, 0x4e, 0xcd, 0x4b, 0x81, 0xd1, 0x7a, 0x60, 0x51, 0x21, 0x76, 0x28, 0x57, 0x49, 0x85, + 0x8b, 0x27, 0xb0, 0x34, 0xb5, 0xa8, 0x32, 0x28, 0xb5, 0xb0, 0x34, 0xb5, 0xb8, 0x44, 0x48, 0x84, + 0x8b, 0xb5, 0x10, 0xc4, 0x97, 0x60, 0x54, 0x60, 0xd4, 0xe0, 0x0c, 0x82, 0x70, 0x94, 0xf4, 0xb8, + 0xb8, 0xa0, 0xaa, 0x0a, 0x72, 0x2a, 0x85, 0x84, 0xb8, 0x58, 0x4a, 0x2a, 0x0b, 0x52, 0xa1, 0x4a, + 0xc0, 0x6c, 0x21, 0x1e, 0x2e, 0xc6, 0x3c, 0x09, 0x26, 0xa0, 0x00, 0x73, 0x10, 0x63, 0x9e, 0x91, + 0x03, 0x17, 0xbb, 0x13, 0xc4, 0x02, 0x21, 0x53, 0x2e, 0x56, 0xb0, 0x56, 0x21, 0x51, 0x3d, 0x98, + 0x13, 0x90, 0x2d, 0x94, 0x12, 0x46, 0x17, 0x06, 0xda, 0xa0, 0xc4, 0x90, 0xc4, 0x06, 0x76, 0xa7, + 0x31, 0x20, 0x00, 0x00, 0xff, 0xff, 0x9b, 0xe0, 0x69, 0x75, 0xe2, 0x00, 0x00, 0x00, +} diff --git a/grpc/proto/backend/backend.proto b/grpc/proto/backend/backend.proto new file mode 100644 index 0000000..b1fdde2 --- /dev/null +++ b/grpc/proto/backend/backend.proto @@ -0,0 +1,43 @@ +// Copyright (C) 2016 Sebastian 'tokkee' Harl +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +// TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +// OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package backend; + +service Backend { + // Execute a query. + rpc Query(QueryRequest) returns (QueryReply) {}; +} + +message QueryRequest { + // The query string: ... + string query = 1; +} + +// A query result. +message QueryReply { + string type = 1; + int64 n = 2; +} diff --git a/grpc/server/main.go b/grpc/server/main.go new file mode 100644 index 0000000..83ccfb5 --- /dev/null +++ b/grpc/server/main.go @@ -0,0 +1,113 @@ +// Copyright (C) 2016 Sebastian 'tokkee' Harl +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +// TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +// OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// server is the backend server implementation. +package main + +import ( + "flag" + "log" + "net" + "strconv" + "strings" + + "golang.org/x/net/context" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/peer" + + pb "tokkee.org/go-talk/grpc/proto/backend" +) + +var ( + listen = flag.String("listen", ":50051", "listening address") +) + +// A server implements the Backend service. +type server struct{} + +// Query runs the user-provided query and returns the result. +func (*server) Query(ctx context.Context, in *pb.QueryRequest) (*pb.QueryReply, error) { + if peer, ok := peer.FromContext(ctx); ok { + log.Printf("Query() called from %v", peer) + } else { + log.Print("No peer information available") + } + + t, n, err := runQuery(in.Query) + if err != nil { + return nil, err + } + + return &pb.QueryReply{ + Type: t, + N: n, + }, nil +} + +// runQuery executes a query. It implements a highly sophisticated query +// engine. +func runQuery(q string) (string, int64, error) { + fields := strings.SplitN(q, " ", 2) + if len(fields) != 2 { + return "", 0, grpc.Errorf(codes.InvalidArgument, "invalid query %q: want ", q) + } + + var n int64 + cmd, arg := strings.ToUpper(fields[0]), fields[1] + switch cmd { + case "COUNT": + n = int64(len(arg)) + case "RANDOM": + i, err := strconv.Atoi(arg) + if err != nil { + return "", 0, grpc.Errorf(codes.InvalidArgument, "RANDOM: %v", err) + } + + // Chosen by fair dice roll. + n = 4 + if i <= 4 { + n = 0 + } + default: + return "", 0, grpc.Errorf(codes.InvalidArgument, "unknown query command %q", cmd) + } + + return cmd, n, nil +} + +func main() { + flag.Parse() + + l, err := net.Listen("tcp", *listen) + if err != nil { + log.Fatalf("Failed to listen on %s: %v", *listen, err) + } + + log.Printf("Listening on %s ...", *listen) + s := grpc.NewServer() + pb.RegisterBackendServer(s, &server{}) + s.Serve(l) +} diff --git a/grpc/ui/main.go b/grpc/ui/main.go new file mode 100644 index 0000000..88442df --- /dev/null +++ b/grpc/ui/main.go @@ -0,0 +1,228 @@ +// Copyright (C) 2016 Sebastian 'tokkee' Harl +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +// TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +// PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR +// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +// EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; +// OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, +// WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +// OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// ui is a web-based UI interacting with the backend server. +package main + +import ( + "bytes" + "errors" + "flag" + "fmt" + "html/template" + "io" + "log" + "net/http" + "strings" + "sync" + "time" + + "golang.org/x/net/context" + "google.golang.org/grpc" + + pb "tokkee.org/go-talk/grpc/proto/backend" +) + +var ( + listen = flag.String("listen", ":8080", "listen address") + backend = flag.String("backend", "localhost:50051", "backend server address") + + queryTmpl = template.Must(template.New("query").Parse(` +Backend Query + +
+ Query: + +
+{{range .Responses}} +

{{.Request}} => {{.Type}}: {{.N}}

+{{end}} + + +`)) +) + +type server struct { + c pb.BackendClient +} + +type queryResponse struct { + Request string + Type string + N int64 +} + +// runQueries executes multiple queries, separated by semicolon, in parallel. +func (s *server) runQueries(ctx context.Context, query string) ([]queryResponse, error) { + requests := strings.Split(query, ";") + for i, r := range requests { + requests[i] = strings.TrimSpace(r) + } + + responses := make([]queryResponse, len(requests)) + errCh := make(chan error, len(requests)) + + for i, req := range requests { + go func(i int, req string) { + res, err := s.c.Query(ctx, &pb.QueryRequest{Query: req}) + defer func() { errCh <- err }() + if err != nil { + return + } + + responses[i] = queryResponse{ + Request: req, + Type: res.Type, + N: res.N, + } + }(i, req) + } + + timeout := time.After(50 * time.Millisecond) + + for _ = range requests { + select { + case err := <-errCh: + if err != nil { + return nil, err + } + + case <-timeout: + return nil, errors.New("request timed out") + } + } + + return responses, nil +} + +func (s *server) query(ctx context.Context, w http.ResponseWriter, r *http.Request) { + data := &struct { + Query string + Responses []queryResponse + }{} + + data.Query = r.Form.Get("q") + if data.Query != "" { + if r.Method != "POST" { + // RFC 2616 requires us to set the "Allow" header. + w.Header().Add("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + // TODO: use and validate XSRF tokens + + res, err := s.runQueries(ctx, data.Query) + if err != nil { + http.Error(w, fmt.Sprintf("query failed: %v", err), http.StatusBadRequest) + return + } + data.Responses = res + } + + var buf bytes.Buffer + if err := queryTmpl.Execute(&buf, data); err != nil { + http.Error(w, fmt.Sprintf("template error: %v", err), http.StatusInternalServerError) + return + } + io.Copy(w, &buf) +} + +type handler func(context.Context, http.ResponseWriter, *http.Request) + +// A mux multiplexes incoming requests based on the first part of the incoming +// request URI. It differs from http.ServerMux in that it only support routing +// based on the first part of the request path and it adds context handling +// and form parsing. mux implements http.Handler. +type mux struct { + mu sync.RWMutex + handlers map[string]handler +} + +// Handle registers a handler for the specified path. It panics if a handler +// has already been registered for the same path or if the path includes a +// slash. +func (m *mux) Handle(path string, h handler) { + if strings.Index(path, "/") != -1 { + panic("invalid path: " + path) + } + if h == nil { + panic("invalid nil handler") + } + + m.mu.Lock() + defer m.mu.Unlock() + + if m.handlers == nil { + m.handlers = make(map[string]handler) + } + if m.handlers[path] != nil { + panic(fmt.Sprintf("duplicate handlers registered for %q", path)) + } + m.handlers[path] = h +} + +// ServeHTTP handles incoming requests using the registered handlers and takes +// care of request-specific setup (context management and form parsing). +func (m *mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + if f := strings.Split(path, "/"); len(f) > 1 { + path = f[0] + } + + if err := r.ParseForm(); err != nil { + log.Printf("Invalid URL query: %v", err) + http.Error(w, fmt.Sprintf("invalid URL query: %v", err), http.StatusBadRequest) + return + } + + m.mu.RLock() + h := m.handlers[path] + m.mu.RUnlock() + + if h == nil { + log.Printf("Not found: %q (%v)", path, r.URL) + http.NotFound(w, r) + return + } + h(context.Background(), w, r) +} + +func main() { + flag.Parse() + + conn, err := grpc.Dial(*backend, grpc.WithInsecure()) + if err != nil { + log.Fatalf("Failed to connect to backend server at %s: %v", *backend, err) + } + defer conn.Close() + + s := &server{c: pb.NewBackendClient(conn)} + m := &mux{} + m.Handle("query", s.query) + log.Printf("Listening on %s ...", *listen) + log.Fatal(http.ListenAndServe(*listen, m)) +} -- 2.30.2