mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-05 22:43:59 +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:
+34
-9
@@ -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
|
||||
}
|
||||
|
||||
+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 {
|
||||
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
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-10
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user