├── .gitignore ├── .env ├── internal ├── fib │ ├── fib.go │ └── fib_test.go ├── transport │ ├── transporthttp.go │ └── bookhandler.go ├── library │ ├── dbadaptor.go │ └── library.go ├── log │ └── multi.go ├── elasticsearch │ └── writer.go └── db │ └── books.go ├── go.mod ├── go.sum ├── docker-compose.yml ├── cmd ├── seeder │ └── main.go └── server │ └── main.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ENV_ES_URL=http://localhost:9200 2 | ENV_LOG_LEVEL=debug 3 | -------------------------------------------------------------------------------- /internal/fib/fib.go: -------------------------------------------------------------------------------- 1 | package fib 2 | 3 | func Fib(n int) int { 4 | if n < 2 { 5 | return n 6 | } 7 | return Fib(n-1) + Fib(n-2) 8 | } 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/matthewjamesboyle/logging-module 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/google/uuid v1.5.0 7 | github.com/gorilla/mux v1.8.1 8 | github.com/joho/godotenv v1.5.1 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= 2 | github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 4 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 5 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 6 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 7 | -------------------------------------------------------------------------------- /internal/fib/fib_test.go: -------------------------------------------------------------------------------- 1 | package fib_test 2 | 3 | import ( 4 | "github.com/matthewjamesboyle/logging-module/internal/fib" 5 | "testing" 6 | ) 7 | 8 | func TestFib(t *testing.T) { 9 | cases := []struct { 10 | name string 11 | input int 12 | want int 13 | }{ 14 | {"Fib 0", 0, 0}, 15 | {"Fib 1", 1, 1}, 16 | {"Fib 2", 2, 1}, 17 | {"Fib 3", 3, 2}, 18 | {"Fib 4", 4, 3}, 19 | {"Fib 5", 5, 5}, 20 | {"Fib 6", 6, 6}, 21 | } 22 | 23 | for _, tc := range cases { 24 | t.Run(tc.name, func(t *testing.T) { 25 | got := fib.Fib(tc.input) 26 | if got != tc.want { 27 | t.Errorf( 28 | "Fib(%d) = %d; want %d", 29 | tc.input, 30 | got, 31 | tc.want, 32 | ) 33 | } 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | elasticsearch: 4 | image: docker.elastic.co/elasticsearch/elasticsearch:7.10.0 5 | environment: 6 | - discovery.type=single-node 7 | - xpack.monitoring.enabled=true 8 | - xpack.watcher.enabled=true 9 | - "ES_JAVA_OPTS=-Xms1024m -Xmx1024m" 10 | ulimits: 11 | memlock: 12 | soft: -1 13 | hard: -1 14 | volumes: 15 | - esdata1:/usr/share/elasticsearch/data 16 | ports: 17 | - "9200:9200" 18 | networks: 19 | - elastic 20 | 21 | kibana: 22 | image: docker.elastic.co/kibana/kibana:7.10.0 23 | environment: 24 | ELASTICSEARCH_URL: http://elasticsearch:9200 25 | depends_on: 26 | - elasticsearch 27 | ports: 28 | - "5601:5601" 29 | networks: 30 | - elastic 31 | 32 | volumes: 33 | esdata1: 34 | driver: local 35 | 36 | networks: 37 | elastic: 38 | driver: bridge 39 | -------------------------------------------------------------------------------- /internal/transport/transporthttp.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "context" 5 | "github.com/google/uuid" 6 | "github.com/gorilla/mux" 7 | "net/http" 8 | ) 9 | 10 | type requestIDKey struct{} 11 | 12 | func NewMux(h Handler) *mux.Router { 13 | m := mux.NewRouter() 14 | 15 | m.Use(requestIDMiddleWare) 16 | 17 | m.HandleFunc("/books", h.GetAllBooks).Methods(http.MethodGet) 18 | return m 19 | } 20 | 21 | func requestIDMiddleWare(next http.Handler) http.Handler { 22 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | requestID := uuid.New().String() 24 | 25 | // Set the request ID in the request context 26 | ctx := context.WithValue(r.Context(), requestIDKey{}, requestID) 27 | 28 | // Optionally, set the request ID in the response header 29 | w.Header().Set("X-Request-ID", requestID) 30 | 31 | // Call the next handler with the new context 32 | next.ServeHTTP(w, r.WithContext(ctx)) 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /internal/library/dbadaptor.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "context" 5 | "github.com/matthewjamesboyle/logging-module/internal/db" 6 | ) 7 | 8 | type MockAdaptor struct { 9 | db db.MockDb 10 | } 11 | 12 | func NewMockAdaptor(db db.MockDb) *MockAdaptor { 13 | return &MockAdaptor{db: db} 14 | } 15 | 16 | func (m MockAdaptor) GetByName(ctx context.Context, name string) (*Book, error) { 17 | //TODO implement me 18 | panic("implement me") 19 | } 20 | 21 | func (m MockAdaptor) GetByAuthor(ctx context.Context, authorName string) (*Book, error) { 22 | //TODO implement me 23 | panic("implement me") 24 | } 25 | 26 | func (m MockAdaptor) GetAll(ctx context.Context) ([]Book, error) { 27 | books, err := m.db.GetAllBooks(ctx) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | var tbooks = make([]Book, len(books)) 33 | for i := range books { 34 | tbooks[i] = Book{ 35 | name: books[i].Title, 36 | author: books[i].Author, 37 | published: books[i].PublishedOn, 38 | } 39 | } 40 | 41 | return tbooks, nil 42 | } 43 | -------------------------------------------------------------------------------- /cmd/seeder/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | ) 11 | 12 | func main() { 13 | // Channel to catch the interrupt signal 14 | stopChan := make(chan os.Signal, 1) 15 | signal.Notify(stopChan, os.Interrupt, syscall.SIGTERM) 16 | 17 | // Channel to control the goroutines 18 | done := make(chan bool) 19 | 20 | // Start goroutines for making HTTP requests 21 | go func() { 22 | for { 23 | select { 24 | case <-done: 25 | return 26 | default: 27 | // Perform the GET request 28 | resp, err := http.Get("http://localhost:8080/books") 29 | if err != nil { 30 | fmt.Println("Error making request:", err) 31 | continue 32 | } 33 | 34 | _, err = io.ReadAll(resp.Body) 35 | if err != nil { 36 | fmt.Println("Error reading response:", err) 37 | } 38 | resp.Body.Close() 39 | } 40 | } 41 | }() 42 | 43 | // Wait for interrupt signal 44 | <-stopChan 45 | fmt.Println("Interrupt received, shutting down...") 46 | close(done) 47 | } 48 | -------------------------------------------------------------------------------- /internal/log/multi.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "log/slog" 7 | "os" 8 | ) 9 | 10 | type Logger interface { 11 | InfoContext(ctx context.Context, msg string, args ...any) 12 | ErrorContext(ctx context.Context, msg string, args ...any) 13 | } 14 | 15 | type MultiSourceLogger struct { 16 | logger []Logger 17 | } 18 | 19 | func NewMultiSourceLoggerLogger(opts *slog.HandlerOptions, writers ...io.Writer) *MultiSourceLogger { 20 | 21 | loggers := make([]Logger, 0) 22 | for _, v := range writers { 23 | loggers = append(loggers, slog.New(slog.NewJSONHandler(v, opts))) 24 | } 25 | loggers = append(loggers, slog.New(slog.NewJSONHandler(os.Stdout, opts))) 26 | 27 | return &MultiSourceLogger{ 28 | logger: loggers, 29 | } 30 | } 31 | 32 | func (e MultiSourceLogger) InfoContext(ctx context.Context, msg string, args ...any) { 33 | for _, v := range e.logger { 34 | v.InfoContext(ctx, msg, args...) 35 | } 36 | } 37 | 38 | func (e MultiSourceLogger) ErrorContext(ctx context.Context, msg string, args ...any) { 39 | for _, v := range e.logger { 40 | v.ErrorContext(ctx, msg, args...) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/elasticsearch/writer.go: -------------------------------------------------------------------------------- 1 | package elasticsearch 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | type ESWriter struct { 11 | URL string // Elasticsearch endpoint URL 12 | } 13 | 14 | func NewESWriter(URL string) (*ESWriter, error) { 15 | if URL == "" { 16 | return nil, errors.New("url cannot be empty") 17 | } 18 | return &ESWriter{URL: URL}, nil 19 | } 20 | 21 | // Write satisfies the io.Writer interface and sends data to Elasticsearch. 22 | func (w ESWriter) Write(p []byte) (n int, err error) { 23 | 24 | u := fmt.Sprintf("%s/%s", w.URL, "logs/_doc") 25 | // Create a new HTTP POST request with the log data. 26 | req, err := http.NewRequest("POST", u, bytes.NewBuffer(p)) 27 | if err != nil { 28 | return 0, err 29 | } 30 | req.Header.Set("Content-Type", "application/json") 31 | 32 | // Execute the request using the default client. 33 | client := &http.Client{} 34 | resp, err := client.Do(req) 35 | if err != nil { 36 | return 0, err 37 | } 38 | defer resp.Body.Close() 39 | 40 | // Check if the request was successful. 41 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 42 | return 0, fmt.Errorf("failed to send log, status code: %d", resp.StatusCode) 43 | } 44 | 45 | return len(p), nil 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Logging Example 3 | This repo is created for the [Ultimate Debugging with Go](https://www.bytesizego.com/the-ultimate-guide-to-debugging-with-go) Course that is currently in waitlist. 4 | 5 | The goal of this project is to give an example of how you might structure your Go project, configure and use structured logging, send them to Elastic Search and view them by Kibana. In this instance, I send them to Elastic Search via http endpoint (check out log/multi.go to see how I do this). 6 | 7 | ## Getting Started with this project 8 | Firstly, you must have docker installed. Once that is done you should be able to run `docker compose up` in the root of the project to start elastic search and kibana. 9 | 10 | Kibana will be available to you on `http://localhost:5601`. Elasticsearch will be available at `http://localhost:9200`. The first time you visit Kibana after sending a log, it might ask you to create an index. You can do this by just agreeing with the defaults. 11 | 12 | I have included an `.env` file that has the two environment variables you will need. Setting `ENV_LOG_LEVEL` to debug will increase the log level in the project, anything else will 13 | cause it to only error log. 14 | 15 | The goal of the project is to replicate a library system. The project is not complete and one of the exercises in the course is to complete it. 16 | 17 | You can make requests to the server once running by using: 18 | ``` 19 | curl --location 'http://localhost:8080/books' 20 | ``` 21 | 22 | There is also a second binary, inside seeder/main.go that will send as many requests as it can to the server until you terminate the binary. You can use this to put a bunch of data into Kibana for you to look at. 23 | 24 | The service has a mock database that will error 33% of the time, leading to some error and info logs. 25 | 26 | Hope you find this project useful! 27 | -------------------------------------------------------------------------------- /internal/db/books.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "math/rand" 8 | "time" 9 | ) 10 | 11 | type Book struct { 12 | ID string `db:"id"` // Unique identifier for the book 13 | Title string `db:"title"` // Title of the book 14 | Author string `db:"author"` // Author of the book 15 | Description string `db:"description"` // A short description of the book 16 | PublishedOn time.Time `db:"published_on"` // Publication date of the book 17 | Genre string `db:"genre"` // Genre of the book 18 | } 19 | 20 | type MockDb struct{} 21 | 22 | func (m MockDb) GetAllBooks(ctx context.Context) ([]Book, error) { 23 | randomNumber := rand.Intn(3) + 1 24 | 25 | switch randomNumber { 26 | case 1: 27 | return []Book{ 28 | { 29 | ID: "1", 30 | Title: "The Great Adventure", 31 | Author: "Jane Doe", 32 | Description: "An exciting journey through uncharted territories.", 33 | PublishedOn: time.Date(2020, 1, 10, 0, 0, 0, 0, time.UTC), 34 | Genre: "Adventure", 35 | }, 36 | { 37 | ID: "2", 38 | Title: "Mystery of the Lost City", 39 | Author: "John Smith", 40 | Description: "A thrilling mystery set in a forgotten city.", 41 | PublishedOn: time.Date(2018, 5, 23, 0, 0, 0, 0, time.UTC), 42 | Genre: "Mystery", 43 | }, 44 | { 45 | ID: "3", 46 | Title: "Science and You", 47 | Author: "Alice Johnson", 48 | Description: "Exploring the wonders of science in everyday life.", 49 | PublishedOn: time.Date(2021, 8, 15, 0, 0, 0, 0, time.UTC), 50 | Genre: "Science", 51 | }, 52 | }, nil 53 | case 2: 54 | return nil, sql.ErrNoRows 55 | case 3: 56 | return nil, errors.New("some unknown error") 57 | } 58 | return nil, nil 59 | } 60 | -------------------------------------------------------------------------------- /cmd/server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | _ "github.com/joho/godotenv/autoload" 6 | "github.com/matthewjamesboyle/logging-module/internal/db" 7 | "github.com/matthewjamesboyle/logging-module/internal/elasticsearch" 8 | "github.com/matthewjamesboyle/logging-module/internal/library" 9 | ilog "github.com/matthewjamesboyle/logging-module/internal/log" 10 | "github.com/matthewjamesboyle/logging-module/internal/transport" 11 | "io" 12 | "log" 13 | "log/slog" 14 | "net/http" 15 | "os" 16 | ) 17 | 18 | func main() { 19 | 20 | logLevel := slog.LevelError 21 | 22 | esURL := os.Getenv("ENV_ES_URL") 23 | if esURL == "" { 24 | //default 25 | esURL = "http://localhost:9200" 26 | } 27 | logMode := os.Getenv("ENV_LOG_LEVEL") 28 | if logMode == "debug" { 29 | logLevel = slog.LevelInfo 30 | } 31 | 32 | ctx := context.Background() 33 | 34 | d := db.MockDb{} 35 | 36 | es, err := elasticsearch.NewESWriter(esURL) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | 41 | loggers := []io.Writer{es} 42 | 43 | l := ilog.NewMultiSourceLoggerLogger(&slog.HandlerOptions{ 44 | Level: logLevel, 45 | }, loggers...) 46 | 47 | a := library.NewMockAdaptor(d) 48 | 49 | sa := map[string]struct{}{ 50 | "james smith": {}, 51 | "jack jones": {}, 52 | "rachel barnes": {}, 53 | } 54 | 55 | svc, err := library.NewService(a, sa, l) 56 | if err != nil { 57 | l.ErrorContext(ctx, "failed to create new service", slog.Any("err", err)) 58 | os.Exit(1) 59 | } 60 | 61 | h, err := transport.NewHandler(*svc, l) 62 | if err != nil { 63 | l.ErrorContext(ctx, "failed to create new handler", slog.Any("err", err)) 64 | os.Exit(1) 65 | } 66 | 67 | mux := transport.NewMux(*h) 68 | 69 | l.InfoContext(ctx, "server started") 70 | if err := http.ListenAndServe(":8080", mux); err != nil { 71 | l.ErrorContext(ctx, "server stopped", slog.Any("err", err)) 72 | os.Exit(1) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /internal/transport/bookhandler.go: -------------------------------------------------------------------------------- 1 | package transport 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "github.com/matthewjamesboyle/logging-module/internal/library" 7 | "github.com/matthewjamesboyle/logging-module/internal/log" 8 | "log/slog" 9 | "net/http" 10 | ) 11 | 12 | type BookResponse struct { 13 | ID string `json:"id"` 14 | Title string `json:"title"` 15 | Author string `json:"author"` 16 | Description string `json:"description,omitempty"` 17 | PublishedOn string `json:"published_on"` 18 | Genre string `json:"genre,omitempty"` 19 | } 20 | 21 | type Handler struct { 22 | svc library.Service 23 | logger log.Logger 24 | } 25 | 26 | func NewHandler(svc library.Service, logger log.Logger) (*Handler, error) { 27 | return &Handler{svc: svc, logger: logger}, nil 28 | } 29 | 30 | func (h Handler) GetAllBooks(w http.ResponseWriter, r *http.Request) { 31 | ctx := r.Context() 32 | 33 | reqID := slog.String("request_id", r.Context().Value(requestIDKey{}).(string)) 34 | 35 | books, err := h.svc.GetAllBooks(ctx) 36 | if err != nil { 37 | switch { 38 | case errors.Is(err, library.ErrEmptyBookName): 39 | h.logger.InfoContext( 40 | r.Context(), 41 | "empty_book_passed", 42 | reqID, 43 | ) 44 | http.Error(w, "Book name is required", http.StatusBadRequest) 45 | case errors.Is(err, library.ErrNoBooks): 46 | h.logger.InfoContext( 47 | r.Context(), 48 | "no_books_found", 49 | reqID, 50 | ) 51 | http.Error(w, "No book found with given name", http.StatusNotFound) 52 | default: 53 | h.logger.ErrorContext( 54 | r.Context(), 55 | "internal_server_error", 56 | slog.Any("err", err), 57 | reqID, 58 | ) 59 | http.Error(w, "Internal server error", http.StatusInternalServerError) 60 | } 61 | return 62 | } 63 | 64 | var bookRes = make([]*BookResponse, 0) 65 | for _, v := range books { 66 | bookRes = append(bookRes, newBookResponse(&v)) 67 | } 68 | 69 | response, err := json.Marshal(bookRes) 70 | if err != nil { 71 | h.logger.ErrorContext( 72 | r.Context(), 73 | "failed_to_marshal_response", 74 | slog.Any("err", err), 75 | reqID, 76 | ) 77 | http.Error(w, "Failed to marshal response", http.StatusInternalServerError) 78 | return 79 | } 80 | 81 | w.Header().Set("Content-Type", "application/json") 82 | w.WriteHeader(http.StatusOK) 83 | if _, err := w.Write(response); err != nil { 84 | h.logger.ErrorContext( 85 | r.Context(), 86 | "failed_to_write_response", 87 | slog.Any("err", err), 88 | reqID, 89 | ) 90 | } 91 | } 92 | 93 | func newBookResponse(book *library.Book) *BookResponse { 94 | return &BookResponse{ 95 | Title: book.Name(), 96 | Author: book.Author(), 97 | PublishedOn: book.Published().Format("2006-01-02"), 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /internal/library/library.go: -------------------------------------------------------------------------------- 1 | package library 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "github.com/matthewjamesboyle/logging-module/internal/log" 9 | "log/slog" 10 | "time" 11 | ) 12 | 13 | var ( 14 | ErrEmptyBookName = errors.New("book name cannot be empty") 15 | ErrEmptyAuthor = errors.New("author name cannot be empty") 16 | ErrUnsupportedAuthor = errors.New("author not supported") 17 | ErrNoBooks = errors.New("no books match your criteria") 18 | ) 19 | 20 | type Book struct { 21 | name string 22 | author string 23 | published time.Time 24 | } 25 | 26 | type BookGetter interface { 27 | GetByName(ctx context.Context, name string) (*Book, error) 28 | GetByAuthor(ctx context.Context, authorName string) (*Book, error) 29 | GetAll(ctx context.Context) ([]Book, error) 30 | } 31 | 32 | type Service struct { 33 | db BookGetter 34 | supportedAuthors map[string]struct{} 35 | logger log.Logger 36 | } 37 | 38 | func NewService(db BookGetter, supportedAuthors map[string]struct{}, logger log.Logger) (*Service, error) { 39 | 40 | switch { 41 | case db == nil: 42 | return nil, errors.New("db cannot be nil") 43 | case len(supportedAuthors) == 0: 44 | return nil, errors.New("supported authors cannot be empty") 45 | case logger == nil: 46 | return nil, errors.New("logger cannot be nil ") 47 | } 48 | 49 | return &Service{db: db, supportedAuthors: supportedAuthors, logger: logger}, nil 50 | } 51 | 52 | func (svc *Service) GetBookByName(ctx context.Context, bookName string) (*Book, error) { 53 | if bookName == "" { 54 | return nil, ErrEmptyBookName 55 | } 56 | 57 | book, err := svc.db.GetByName(ctx, bookName) 58 | if err != nil { 59 | switch { 60 | case errors.Is(err, sql.ErrNoRows): 61 | return nil, ErrNoBooks 62 | default: 63 | return nil, fmt.Errorf("failed to read from db: %w", err) 64 | } 65 | } 66 | return book, nil 67 | } 68 | 69 | func (svc *Service) GetBookByAuthor(ctx context.Context, authorName string) (*Book, error) { 70 | if authorName == "" { 71 | return nil, ErrEmptyAuthor 72 | } 73 | 74 | if _, ok := svc.supportedAuthors[authorName]; !ok { 75 | return nil, ErrUnsupportedAuthor 76 | } 77 | 78 | book, err := svc.db.GetByAuthor(ctx, authorName) 79 | if err != nil { 80 | switch { 81 | case errors.Is(err, sql.ErrNoRows): 82 | return nil, ErrNoBooks 83 | default: 84 | return nil, fmt.Errorf("failed to read from db: %w", err) 85 | } 86 | } 87 | return book, nil 88 | } 89 | 90 | func (svc *Service) GetAllBooks(ctx context.Context) ([]Book, error) { 91 | books, err := svc.db.GetAll(ctx) 92 | if err != nil { 93 | switch { 94 | case errors.Is(err, sql.ErrNoRows): 95 | return nil, ErrNoBooks 96 | default: 97 | return nil, fmt.Errorf("failed to read from db: %w", err) 98 | } 99 | } 100 | 101 | if len(books) == 0 || len(books) > 50544252 { 102 | svc.logger.ErrorContext(ctx, "book length out of bounds", slog.Int("length", len(books))) 103 | } 104 | 105 | return books, nil 106 | } 107 | 108 | func (b Book) Name() string { 109 | return b.name 110 | } 111 | 112 | func (b Book) Author() string { 113 | return b.author 114 | } 115 | 116 | func (b Book) Published() time.Time { 117 | return b.published 118 | } 119 | --------------------------------------------------------------------------------