mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-28 18:23:10 +00:00
perf(dispatch): typed Context.Command/CommandArgs/RegexMatch fields
Move the three conventional Values keys ("command", "command_args", "regex_match") to typed fields on Context. Router and group routing write the fields directly; the Values map is allocated lazily via the new Set method and reserved for user-defined custom keys.
Allocation impact (M4 Max, b.Loop()):
DispatchCommand: 5 allocs/op -> 1, 153ns -> 69ns (-55%)
DispatchTextRegex: 5 allocs/op -> 2, 181ns -> 107ns (-41%)
DispatchFilter: 2 allocs/op -> 1, 32ns -> 19ns (-41%)
NewContext: 5.79ns -> 1.60ns
Trade-off: Context struct grew from ~48B to ~96B (three new fields), so filter-only paths pay ~50B more per dispatch. Command/regex paths save ~320B + 4 allocs each, which dominates for typical bot workloads.
Handlers reading c.Values["command"], c.Values["command_args"], or c.Values["regex_match"] now get nil; the typed fields c.Command, c.CommandArgs, c.RegexMatch are the new accessors. Custom keys still work via c.Set(k, v) and c.Values[k].
This commit is contained in:
@@ -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)
|
||||||
@@ -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%)
|
||||||
+34
-9
@@ -21,20 +21,45 @@ import (
|
|||||||
// Update is the raw update; payload-typed handlers also receive a
|
// Update is the raw update; payload-typed handlers also receive a
|
||||||
// narrowed pointer to one of its sub-fields.
|
// 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 is the matched bot command (e.g. "/start"); empty when the
|
||||||
// "command_args": string, everything after the command
|
// route is not a command match.
|
||||||
// "regex_match": []string, regex sub-matches when OnText matches
|
//
|
||||||
|
// 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 {
|
type Context struct {
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
Bot *client.Bot
|
Bot *client.Bot
|
||||||
Update *api.Update
|
Update *api.Update
|
||||||
Values map[string]any
|
Command string
|
||||||
|
CommandArgs string
|
||||||
|
RegexMatch []string
|
||||||
|
Values map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContext constructs a Context. Used by Router internally; exposed for
|
// NewContext constructs a Context. Used by Router internally; exposed for
|
||||||
// custom test harnesses.
|
// custom test harnesses.
|
||||||
func NewContext(ctx context.Context, b *client.Bot, u *api.Update) *Context {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -138,8 +138,8 @@ func (r *Router) runGroupHandlers(c *Context, m *api.Message, g int) (matched bo
|
|||||||
if route.group != g || route.cmd != cmd {
|
if route.group != g || route.cmd != cmd {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.Values["command"] = cmd
|
c.Command = cmd
|
||||||
c.Values["command_args"] = args
|
c.CommandArgs = args
|
||||||
if err := route.handler(c, m); err != nil {
|
if err := route.handler(c, m); err != nil {
|
||||||
if errors.Is(err, ErrContinueGroups) {
|
if errors.Is(err, ErrContinueGroups) {
|
||||||
continue
|
continue
|
||||||
@@ -159,7 +159,7 @@ func (r *Router) runGroupHandlers(c *Context, m *api.Message, g int) (matched bo
|
|||||||
if subs == nil {
|
if subs == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.Values["regex_match"] = subs
|
c.RegexMatch = subs
|
||||||
if err := route.handler(c, m); err != nil {
|
if err := route.handler(c, m); err != nil {
|
||||||
if errors.Is(err, ErrContinueGroups) {
|
if errors.Is(err, ErrContinueGroups) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
+4
-4
@@ -467,8 +467,8 @@ func (r *Router) handleMessage(c *Context, m *api.Message) error {
|
|||||||
if cmd, args, ok := extractCommand(m); ok {
|
if cmd, args, ok := extractCommand(m); ok {
|
||||||
for _, route := range r.commands {
|
for _, route := range r.commands {
|
||||||
if route.cmd == cmd {
|
if route.cmd == cmd {
|
||||||
c.Values["command"] = cmd
|
c.Command = cmd
|
||||||
c.Values["command_args"] = args
|
c.CommandArgs = args
|
||||||
return route.handler(c, m)
|
return route.handler(c, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -477,7 +477,7 @@ func (r *Router) handleMessage(c *Context, m *api.Message) error {
|
|||||||
if m.Text != "" {
|
if m.Text != "" {
|
||||||
for _, route := range r.texts {
|
for _, route := range r.texts {
|
||||||
if subs := route.re.FindStringSubmatch(m.Text); subs != nil {
|
if subs := route.re.FindStringSubmatch(m.Text); subs != nil {
|
||||||
c.Values["regex_match"] = subs
|
c.RegexMatch = subs
|
||||||
return route.handler(c, m)
|
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 {
|
func (r *Router) handleCallback(c *Context, q *api.CallbackQuery) error {
|
||||||
for _, route := range r.callbacks {
|
for _, route := range r.callbacks {
|
||||||
if subs := route.re.FindStringSubmatch(q.Data); subs != nil {
|
if subs := route.re.FindStringSubmatch(q.Data); subs != nil {
|
||||||
c.Values["regex_match"] = subs
|
c.RegexMatch = subs
|
||||||
return route.handler(c, q)
|
return route.handler(c, q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-10
@@ -52,7 +52,7 @@ func TestRouter_OnCommandMatches(t *testing.T) {
|
|||||||
r := New(b)
|
r := New(b)
|
||||||
hit := make(chan string, 1)
|
hit := make(chan string, 1)
|
||||||
r.OnCommand("/start", func(c *Context, m *api.Message) error {
|
r.OnCommand("/start", func(c *Context, m *api.Message) error {
|
||||||
hit <- c.Values["command"].(string)
|
hit <- c.Command
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ func TestRouter_OnText(t *testing.T) {
|
|||||||
r := New(client.New("t"))
|
r := New(client.New("t"))
|
||||||
hit := make(chan []string, 1)
|
hit := make(chan []string, 1)
|
||||||
r.OnText(`^hello (\w+)$`, func(c *Context, m *api.Message) error {
|
r.OnText(`^hello (\w+)$`, func(c *Context, m *api.Message) error {
|
||||||
hit <- c.Values["regex_match"].([]string)
|
hit <- c.RegexMatch
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -164,10 +164,7 @@ func TestRouter_NonASCIICommand(t *testing.T) {
|
|||||||
r := New(client.New("t"))
|
r := New(client.New("t"))
|
||||||
hit := make(chan [2]string, 1)
|
hit := make(chan [2]string, 1)
|
||||||
r.OnCommand("/старт", func(c *Context, m *api.Message) error {
|
r.OnCommand("/старт", func(c *Context, m *api.Message) error {
|
||||||
hit <- [2]string{
|
hit <- [2]string{c.Command, c.CommandArgs}
|
||||||
c.Values["command"].(string),
|
|
||||||
c.Values["command_args"].(string),
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -180,16 +177,15 @@ func TestRouter_NonASCIICommand(t *testing.T) {
|
|||||||
require.Equal(t, "аргумент", got[1])
|
require.Equal(t, "аргумент", got[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRouter_CommandValuesNotLeakedOnNoMatch verifies that c.Values["command"]
|
// TestRouter_CommandValuesNotLeakedOnNoMatch verifies that c.Command is
|
||||||
// is not set when a command entity is present but no route matches, so a
|
// empty when a command entity is present but no route matches, so a
|
||||||
// subsequent text handler doesn't see stale values.
|
// subsequent text handler doesn't see stale values.
|
||||||
func TestRouter_CommandValuesNotLeakedOnNoMatch(t *testing.T) {
|
func TestRouter_CommandValuesNotLeakedOnNoMatch(t *testing.T) {
|
||||||
r := New(client.New("t"))
|
r := New(client.New("t"))
|
||||||
// Register a text handler that should fire as fallback.
|
// Register a text handler that should fire as fallback.
|
||||||
leaked := make(chan bool, 1)
|
leaked := make(chan bool, 1)
|
||||||
r.OnText(`.*`, func(c *Context, m *api.Message) error {
|
r.OnText(`.*`, func(c *Context, m *api.Message) error {
|
||||||
_, hasCmd := c.Values["command"]
|
leaked <- c.Command != ""
|
||||||
leaked <- hasCmd
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
// No OnCommand registered, so the command entity won't match any route.
|
// No OnCommand registered, so the command entity won't match any route.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func handleStart(c *dispatch.Context, m *api.Message) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleCallback(c *dispatch.Context, q *api.CallbackQuery) error {
|
func handleCallback(c *dispatch.Context, q *api.CallbackQuery) error {
|
||||||
groups := c.Values["regex_match"].([]string)
|
groups := c.RegexMatch
|
||||||
current, _ := strconv.Atoi(groups[1])
|
current, _ := strconv.Atoi(groups[1])
|
||||||
if groups[2] == "inc" {
|
if groups[2] == "inc" {
|
||||||
current++
|
current++
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const (
|
|||||||
func makeCtx(bot *client.Bot, upd *api.Update, extra map[string]any) *dispatch.Context {
|
func makeCtx(bot *client.Bot, upd *api.Update, extra map[string]any) *dispatch.Context {
|
||||||
c := dispatch.NewContext(context.Background(), bot, upd)
|
c := dispatch.NewContext(context.Background(), bot, upd)
|
||||||
for k, v := range extra {
|
for k, v := range extra {
|
||||||
c.Values[k] = v
|
c.Set(k, v)
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
@@ -82,7 +82,9 @@ func TestHandleStart_SendsInitialKeyboard(t *testing.T) {
|
|||||||
|
|
||||||
func callbackCtx(bot *client.Bot, q *api.CallbackQuery, groups []string) *dispatch.Context {
|
func callbackCtx(bot *client.Bot, q *api.CallbackQuery, groups []string) *dispatch.Context {
|
||||||
upd := &api.Update{UpdateID: 1, CallbackQuery: q}
|
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 {
|
func callbackQuery(data string, msgID int64, chatID int64) *api.CallbackQuery {
|
||||||
|
|||||||
@@ -111,8 +111,7 @@ func main() {
|
|||||||
|
|
||||||
// page:<n> callbacks — edit message in-place.
|
// page:<n> callbacks — edit message in-place.
|
||||||
router.OnCallback(`^page:(\d+)$`, func(c *dispatch.Context, q *api.CallbackQuery) error {
|
router.OnCallback(`^page:(\d+)$`, func(c *dispatch.Context, q *api.CallbackQuery) error {
|
||||||
groups := c.Values["regex_match"].([]string)
|
page, _ := strconv.Atoi(c.RegexMatch[1])
|
||||||
page, _ := strconv.Atoi(groups[1])
|
|
||||||
|
|
||||||
// Acknowledge the tap first.
|
// Acknowledge the tap first.
|
||||||
_, _ = api.AnswerCallbackQuery(c.Ctx, c.Bot, &api.AnswerCallbackQueryParams{
|
_, _ = api.AnswerCallbackQuery(c.Ctx, c.Bot, &api.AnswerCallbackQueryParams{
|
||||||
|
|||||||
Reference in New Issue
Block a user