diff --git a/.benchstats/after_step2.txt b/.benchstats/after_step2.txt new file mode 100644 index 0000000..c753c24 --- /dev/null +++ b/.benchstats/after_step2.txt @@ -0,0 +1,27 @@ +After: static-headers + bool-fast-path + lazy-Context.Values +goos: darwin +goarch: arm64 +cpu: Apple M4 Max + +pkg: github.com/lukaszraczylo/go-telegram/client +BenchmarkCall_BoolResponse-16 4597374 526.3 ns/op 1842 B/op 14 allocs/op +BenchmarkCall_StructResponse-16 3740096 679.6 ns/op 1973 B/op 16 allocs/op +BenchmarkEncodeJSONBody-16 41997352 58.35 ns/op 96 B/op 2 allocs/op +BenchmarkDecodeResult_Bool-16 863273641 2.872 ns/op 0 B/op 0 allocs/op +BenchmarkDecodeResult_Struct-16 19988096 100.3 ns/op 144 B/op 2 allocs/op + +pkg: github.com/lukaszraczylo/go-telegram/dispatch +BenchmarkRouter_DispatchCommand-16 14912385 156.4 ns/op 416 B/op 5 allocs/op +BenchmarkRouter_DispatchTextRegex-16 12495229 187.7 ns/op 428 B/op 5 allocs/op +BenchmarkRouter_DispatchFilter-16 148631502 16.11 ns/op 48 B/op 1 allocs/op +BenchmarkRouter_NewContext-16 1000000000 1.608 ns/op 0 B/op 0 allocs/op +BenchmarkExtractCommand-16 25267845 92.52 ns/op 0 B/op 0 allocs/op + +Cumulative deltas vs baseline: + Call_BoolResponse: 18 -> 14 allocs (-4) + Call_StructResponse: 18 -> 16 allocs (-2) + DecodeResult_Bool: 2 -> 0 allocs (-2, also 50ns -> 2.87ns) + DispatchFilter: 2 -> 1 alloc (-1, also 32ns -> 16ns) + NewContext: 5.79ns -> 1.61ns (-72%) + DispatchCommand: 5 -> 5 allocs (flat — map alloc shifted from NewContext to first Set) + DispatchTextRegex: 5 -> 5 allocs (flat — same reason) diff --git a/.benchstats/after_step3.txt b/.benchstats/after_step3.txt new file mode 100644 index 0000000..9cfa090 --- /dev/null +++ b/.benchstats/after_step3.txt @@ -0,0 +1,27 @@ +After: static-headers + bool-fast-path + lazy-Values + typed-fields +goos: darwin +goarch: arm64 +cpu: Apple M4 Max + +pkg: github.com/lukaszraczylo/go-telegram/client +BenchmarkCall_BoolResponse-16 4597374 526.3 ns/op 1842 B/op 14 allocs/op +BenchmarkCall_StructResponse-16 3740096 679.6 ns/op 1973 B/op 16 allocs/op +BenchmarkEncodeJSONBody-16 41997352 58.35 ns/op 96 B/op 2 allocs/op +BenchmarkDecodeResult_Bool-16 863273641 2.872 ns/op 0 B/op 0 allocs/op +BenchmarkDecodeResult_Struct-16 19988096 100.3 ns/op 144 B/op 2 allocs/op + +pkg: github.com/lukaszraczylo/go-telegram/dispatch +BenchmarkRouter_DispatchCommand-16 34631486 69.19 ns/op 96 B/op 1 allocs/op +BenchmarkRouter_DispatchTextRegex-16 23260198 106.6 ns/op 112 B/op 2 allocs/op +BenchmarkRouter_DispatchFilter-16 126697654 19.03 ns/op 96 B/op 1 allocs/op +BenchmarkRouter_NewContext-16 1000000000 1.600 ns/op 0 B/op 0 allocs/op +BenchmarkExtractCommand-16 27345622 87.25 ns/op 0 B/op 0 allocs/op + +Cumulative deltas vs baseline (allocs/op): + Call_BoolResponse: 18 -> 14 allocs (-4) + Call_StructResponse: 18 -> 16 allocs (-2) + DecodeResult_Bool: 2 -> 0 allocs (-2, also 50ns -> 2.87ns) + DispatchCommand: 5 -> 1 alloc (-4, also 153ns -> 69ns) + DispatchTextRegex: 5 -> 2 allocs (-3, also 181ns -> 107ns) + DispatchFilter: 2 -> 1 alloc (-1, but +48B from larger Context struct) + NewContext: 5.79ns -> 1.60ns (-72%) diff --git a/dispatch/context.go b/dispatch/context.go index e5b6f4c..e5a69d2 100644 --- a/dispatch/context.go +++ b/dispatch/context.go @@ -21,20 +21,45 @@ import ( // Update is the raw update; payload-typed handlers also receive a // narrowed pointer to one of its sub-fields. // -// Values is a per-update bag matchers populate. Conventional keys: +// Command, CommandArgs and RegexMatch are populated by the router for +// the matching route kind; they replace the previous "command", +// "command_args" and "regex_match" entries in Values, which were the +// only conventional keys. Values remains for user-defined custom keys. // -// "command": string, the matched bot command (e.g. "/start") -// "command_args": string, everything after the command -// "regex_match": []string, regex sub-matches when OnText matches +// Command is the matched bot command (e.g. "/start"); empty when the +// route is not a command match. +// +// CommandArgs is everything after the command; empty when no command +// matched or the command had no trailing text. +// +// RegexMatch is the regex sub-matches when an OnText/OnCallback regex +// route matched; nil otherwise. +// +// Values is lazily allocated for user-defined keys. Handlers that don't +// write pay no allocation. Reads against a nil map return the zero +// value. Writers must use Set instead of indexing the map directly. type Context struct { - Ctx context.Context - Bot *client.Bot - Update *api.Update - Values map[string]any + Ctx context.Context + Bot *client.Bot + Update *api.Update + Command string + CommandArgs string + RegexMatch []string + Values map[string]any } // NewContext constructs a Context. Used by Router internally; exposed for // custom test harnesses. func NewContext(ctx context.Context, b *client.Bot, u *api.Update) *Context { - return &Context{Ctx: ctx, Bot: b, Update: u, Values: map[string]any{}} + return &Context{Ctx: ctx, Bot: b, Update: u} +} + +// Set writes key/val into Values, allocating the map on first use. Use +// this instead of `c.Values[k] = v` so the no-write path stays +// allocation-free. +func (c *Context) Set(key string, val any) { + if c.Values == nil { + c.Values = make(map[string]any, 2) + } + c.Values[key] = val } diff --git a/dispatch/groups.go b/dispatch/groups.go index 968b05b..4667a5a 100644 --- a/dispatch/groups.go +++ b/dispatch/groups.go @@ -138,8 +138,8 @@ func (r *Router) runGroupHandlers(c *Context, m *api.Message, g int) (matched bo if route.group != g || route.cmd != cmd { continue } - c.Values["command"] = cmd - c.Values["command_args"] = args + c.Command = cmd + c.CommandArgs = args if err := route.handler(c, m); err != nil { if errors.Is(err, ErrContinueGroups) { continue @@ -159,7 +159,7 @@ func (r *Router) runGroupHandlers(c *Context, m *api.Message, g int) (matched bo if subs == nil { continue } - c.Values["regex_match"] = subs + c.RegexMatch = subs if err := route.handler(c, m); err != nil { if errors.Is(err, ErrContinueGroups) { continue diff --git a/dispatch/router.go b/dispatch/router.go index ba09918..7f82af5 100644 --- a/dispatch/router.go +++ b/dispatch/router.go @@ -467,8 +467,8 @@ func (r *Router) handleMessage(c *Context, m *api.Message) error { if cmd, args, ok := extractCommand(m); ok { for _, route := range r.commands { if route.cmd == cmd { - c.Values["command"] = cmd - c.Values["command_args"] = args + c.Command = cmd + c.CommandArgs = args return route.handler(c, m) } } @@ -477,7 +477,7 @@ func (r *Router) handleMessage(c *Context, m *api.Message) error { if m.Text != "" { for _, route := range r.texts { if subs := route.re.FindStringSubmatch(m.Text); subs != nil { - c.Values["regex_match"] = subs + c.RegexMatch = subs return route.handler(c, m) } } @@ -495,7 +495,7 @@ func (r *Router) handleMessage(c *Context, m *api.Message) error { func (r *Router) handleCallback(c *Context, q *api.CallbackQuery) error { for _, route := range r.callbacks { if subs := route.re.FindStringSubmatch(q.Data); subs != nil { - c.Values["regex_match"] = subs + c.RegexMatch = subs return route.handler(c, q) } } diff --git a/dispatch/router_test.go b/dispatch/router_test.go index fec0683..f51828b 100644 --- a/dispatch/router_test.go +++ b/dispatch/router_test.go @@ -52,7 +52,7 @@ func TestRouter_OnCommandMatches(t *testing.T) { r := New(b) hit := make(chan string, 1) r.OnCommand("/start", func(c *Context, m *api.Message) error { - hit <- c.Values["command"].(string) + hit <- c.Command return nil }) @@ -82,7 +82,7 @@ func TestRouter_OnText(t *testing.T) { r := New(client.New("t")) hit := make(chan []string, 1) r.OnText(`^hello (\w+)$`, func(c *Context, m *api.Message) error { - hit <- c.Values["regex_match"].([]string) + hit <- c.RegexMatch return nil }) @@ -164,10 +164,7 @@ func TestRouter_NonASCIICommand(t *testing.T) { r := New(client.New("t")) hit := make(chan [2]string, 1) r.OnCommand("/старт", func(c *Context, m *api.Message) error { - hit <- [2]string{ - c.Values["command"].(string), - c.Values["command_args"].(string), - } + hit <- [2]string{c.Command, c.CommandArgs} return nil }) @@ -180,16 +177,15 @@ func TestRouter_NonASCIICommand(t *testing.T) { require.Equal(t, "аргумент", got[1]) } -// TestRouter_CommandValuesNotLeakedOnNoMatch verifies that c.Values["command"] -// is not set when a command entity is present but no route matches, so a +// TestRouter_CommandValuesNotLeakedOnNoMatch verifies that c.Command is +// empty when a command entity is present but no route matches, so a // subsequent text handler doesn't see stale values. func TestRouter_CommandValuesNotLeakedOnNoMatch(t *testing.T) { r := New(client.New("t")) // Register a text handler that should fire as fallback. leaked := make(chan bool, 1) r.OnText(`.*`, func(c *Context, m *api.Message) error { - _, hasCmd := c.Values["command"] - leaked <- hasCmd + leaked <- c.Command != "" return nil }) // No OnCommand registered, so the command entity won't match any route. diff --git a/examples/callback/handlers.go b/examples/callback/handlers.go index 129c047..abd1787 100644 --- a/examples/callback/handlers.go +++ b/examples/callback/handlers.go @@ -21,7 +21,7 @@ func handleStart(c *dispatch.Context, m *api.Message) error { } func handleCallback(c *dispatch.Context, q *api.CallbackQuery) error { - groups := c.Values["regex_match"].([]string) + groups := c.RegexMatch current, _ := strconv.Atoi(groups[1]) if groups[2] == "inc" { current++ diff --git a/examples/callback/handlers_test.go b/examples/callback/handlers_test.go index 9266fd3..53f6089 100644 --- a/examples/callback/handlers_test.go +++ b/examples/callback/handlers_test.go @@ -42,7 +42,7 @@ const ( func makeCtx(bot *client.Bot, upd *api.Update, extra map[string]any) *dispatch.Context { c := dispatch.NewContext(context.Background(), bot, upd) for k, v := range extra { - c.Values[k] = v + c.Set(k, v) } return c } @@ -82,7 +82,9 @@ func TestHandleStart_SendsInitialKeyboard(t *testing.T) { func callbackCtx(bot *client.Bot, q *api.CallbackQuery, groups []string) *dispatch.Context { upd := &api.Update{UpdateID: 1, CallbackQuery: q} - return makeCtx(bot, upd, map[string]any{"regex_match": groups}) + c := makeCtx(bot, upd, nil) + c.RegexMatch = groups + return c } func callbackQuery(data string, msgID int64, chatID int64) *api.CallbackQuery { diff --git a/examples/pagination/main.go b/examples/pagination/main.go index cb05e02..8830665 100644 --- a/examples/pagination/main.go +++ b/examples/pagination/main.go @@ -111,8 +111,7 @@ func main() { // page: callbacks — edit message in-place. router.OnCallback(`^page:(\d+)$`, func(c *dispatch.Context, q *api.CallbackQuery) error { - groups := c.Values["regex_match"].([]string) - page, _ := strconv.Atoi(groups[1]) + page, _ := strconv.Atoi(c.RegexMatch[1]) // Acknowledge the tap first. _, _ = api.AnswerCallbackQuery(c.Ctx, c.Bot, &api.AnswerCallbackQueryParams{