Skip to content

Commit

Permalink
common.bind replacement (#2108)
Browse files Browse the repository at this point in the history
This change adds a new interface which when implemented by registered modules let them gain access to the Context, Runtime, State and in the future others without using `common.Bind` but instead through simple methods (as it probably should've always been).

Additionally, it lets defining of both default and named exports and let users more accurately name their exports instead of depending on the magic in common.Bind and goja.

Co-authored-by: Ivan Mirić <ivan@loadimpact.com>
mstoykov and Ivan Mirić authored Aug 25, 2021

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent 37681ac commit 34a7743
Showing 5 changed files with 352 additions and 69 deletions.
88 changes: 87 additions & 1 deletion js/initcontext.go
Original file line number Diff line number Diff line change
@@ -36,6 +36,16 @@ import (
"go.k6.io/k6/js/common"
"go.k6.io/k6/js/compiler"
"go.k6.io/k6/js/modules"
"go.k6.io/k6/js/modules/k6"
"go.k6.io/k6/js/modules/k6/crypto"
"go.k6.io/k6/js/modules/k6/crypto/x509"
"go.k6.io/k6/js/modules/k6/data"
"go.k6.io/k6/js/modules/k6/encoding"
"go.k6.io/k6/js/modules/k6/grpc"
"go.k6.io/k6/js/modules/k6/html"
"go.k6.io/k6/js/modules/k6/http"
"go.k6.io/k6/js/modules/k6/metrics"
"go.k6.io/k6/js/modules/k6/ws"
"go.k6.io/k6/lib"
"go.k6.io/k6/loader"
)
@@ -88,7 +98,7 @@ func NewInitContext(
programs: make(map[string]programWithSource),
compatibilityMode: compatMode,
logger: logger,
modules: modules.GetJSModules(),
modules: getJSModules(),
}
}

@@ -140,14 +150,63 @@ func (i *InitContext) Require(arg string) goja.Value {
}
}

type moduleInstanceCoreImpl struct {
ctxPtr *context.Context
// we can technically put lib.State here as well as anything else
}

func (m *moduleInstanceCoreImpl) GetContext() context.Context {
return *m.ctxPtr
}

func (m *moduleInstanceCoreImpl) GetInitEnv() *common.InitEnvironment {
return common.GetInitEnv(*m.ctxPtr) // TODO thread it correctly instead
}

func (m *moduleInstanceCoreImpl) GetState() *lib.State {
return lib.GetState(*m.ctxPtr) // TODO thread it correctly instead
}

func (m *moduleInstanceCoreImpl) GetRuntime() *goja.Runtime {
return common.GetRuntime(*m.ctxPtr) // TODO thread it correctly instead
}

func toESModuleExports(exp modules.Exports) interface{} {
if exp.Named == nil {
return exp.Default
}
if exp.Default == nil {
return exp.Named
}

result := make(map[string]interface{}, len(exp.Named)+2)

for k, v := range exp.Named {
result[k] = v
}
// Maybe check that those weren't set
result["default"] = exp.Default
// this so babel works with the `default` when it transpiles from ESM to commonjs.
// This should probably be removed once we have support for ESM directly. So that require doesn't get support for
// that while ESM has.
result["__esModule"] = true

return result
}

func (i *InitContext) requireModule(name string) (goja.Value, error) {
mod, ok := i.modules[name]
if !ok {
return nil, fmt.Errorf("unknown module: %s", name)
}
if modV2, ok := mod.(modules.IsModuleV2); ok {
instance := modV2.NewModuleInstance(&moduleInstanceCoreImpl{ctxPtr: i.ctxPtr})
return i.runtime.ToValue(toESModuleExports(instance.GetExports())), nil
}
if perInstance, ok := mod.(modules.HasModuleInstancePerVU); ok {
mod = perInstance.NewModuleInstancePerVU()
}

return i.runtime.ToValue(common.Bind(i.runtime, mod, i.ctxPtr)), nil
}

@@ -255,3 +314,30 @@ func (i *InitContext) Open(ctx context.Context, filename string, args ...string)
}
return i.runtime.ToValue(string(data)), nil
}

func getInternalJSModules() map[string]interface{} {
return map[string]interface{}{
"k6": k6.New(),
"k6/crypto": crypto.New(),
"k6/crypto/x509": x509.New(),
"k6/data": data.New(),
"k6/encoding": encoding.New(),
"k6/net/grpc": grpc.New(),
"k6/html": html.New(),
"k6/http": http.New(),
"k6/metrics": metrics.New(),
"k6/ws": ws.New(),
}
}

func getJSModules() map[string]interface{} {
result := getInternalJSModules()
external := modules.GetJSModules()

// external is always prefixed with `k6/x`
for k, v := range external {
result[k] = v
}

return result
}
121 changes: 86 additions & 35 deletions js/modules/k6/metrics/metrics.go
Original file line number Diff line number Diff line change
@@ -21,7 +21,6 @@
package metrics

import (
"context"
"errors"
"fmt"
"regexp"
@@ -30,7 +29,7 @@ import (
"github.com/dop251/goja"

"go.k6.io/k6/js/common"
"go.k6.io/k6/lib"
"go.k6.io/k6/js/modules"
"go.k6.io/k6/stats"
)

@@ -44,41 +43,50 @@ func checkName(name string) bool {

type Metric struct {
metric *stats.Metric
core modules.InstanceCore
}

// ErrMetricsAddInInitContext is error returned when adding to metric is done in the init context
var ErrMetricsAddInInitContext = common.NewInitContextError("Adding to metrics in the init context is not supported")

func newMetric(ctxPtr *context.Context, name string, t stats.MetricType, isTime []bool) (interface{}, error) {
if lib.GetState(*ctxPtr) != nil {
func (mi *ModuleInstance) newMetric(call goja.ConstructorCall, t stats.MetricType) (*goja.Object, error) {
if mi.GetInitEnv() == nil {
return nil, errors.New("metrics must be declared in the init context")
}
rt := mi.GetRuntime()
c, _ := goja.AssertFunction(rt.ToValue(func(name string, isTime ...bool) (*goja.Object, error) {
// TODO: move verification outside the JS
if !checkName(name) {
return nil, common.NewInitContextError(fmt.Sprintf("Invalid metric name: '%s'", name))
}

// TODO: move verification outside the JS
if !checkName(name) {
return nil, common.NewInitContextError(fmt.Sprintf("Invalid metric name: '%s'", name))
}

valueType := stats.Default
if len(isTime) > 0 && isTime[0] {
valueType = stats.Time
}
valueType := stats.Default
if len(isTime) > 0 && isTime[0] {
valueType = stats.Time
}
m := stats.New(name, t, valueType)

rt := common.GetRuntime(*ctxPtr)
bound := common.Bind(rt, Metric{stats.New(name, t, valueType)}, ctxPtr)
o := rt.NewObject()
err := o.DefineDataProperty("name", rt.ToValue(name), goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_TRUE)
metric := &Metric{metric: m, core: mi.InstanceCore}
o := rt.NewObject()
err := o.DefineDataProperty("name", rt.ToValue(name), goja.FLAG_FALSE, goja.FLAG_FALSE, goja.FLAG_TRUE)
if err != nil {
return nil, err
}
if err = o.Set("add", rt.ToValue(metric.add)); err != nil {
return nil, err
}
return o, nil
}))
v, err := c(call.This, call.Arguments...)
if err != nil {
return nil, err
}
if err = o.Set("add", rt.ToValue(bound["add"])); err != nil {
return nil, err
}
return o, nil

return v.ToObject(rt), nil
}

func (m Metric) Add(ctx context.Context, v goja.Value, addTags ...map[string]string) (bool, error) {
state := lib.GetState(ctx)
func (m Metric) add(v goja.Value, addTags ...map[string]string) (bool, error) {
state := m.core.GetState()
if state == nil {
return false, ErrMetricsAddInInitContext
}
@@ -96,28 +104,71 @@ func (m Metric) Add(ctx context.Context, v goja.Value, addTags ...map[string]str
}

sample := stats.Sample{Time: time.Now(), Metric: m.metric, Value: vfloat, Tags: stats.IntoSampleTags(&tags)}
stats.PushIfNotDone(ctx, state.Samples, sample)
stats.PushIfNotDone(m.core.GetContext(), state.Samples, sample)
return true, nil
}

type Metrics struct{}
type (
// RootModule is the root metrics module
RootModule struct{}
// ModuleInstance represents an instance of the metrics module
ModuleInstance struct {
modules.InstanceCore
}
)

var (
_ modules.IsModuleV2 = &RootModule{}
_ modules.Instance = &ModuleInstance{}
)

// NewModuleInstance implements modules.IsModuleV2 interface
func (*RootModule) NewModuleInstance(m modules.InstanceCore) modules.Instance {
return &ModuleInstance{InstanceCore: m}
}

// New returns a new RootModule.
func New() *RootModule {
return &RootModule{}
}

func New() *Metrics {
return &Metrics{}
// GetExports returns the exports of the metrics module
func (mi *ModuleInstance) GetExports() modules.Exports {
return modules.GenerateExports(mi)
}

func (*Metrics) XCounter(ctx *context.Context, name string, isTime ...bool) (interface{}, error) {
return newMetric(ctx, name, stats.Counter, isTime)
// XCounter is a counter constructor
func (mi *ModuleInstance) XCounter(call goja.ConstructorCall, rt *goja.Runtime) *goja.Object {
v, err := mi.newMetric(call, stats.Counter)
if err != nil {
common.Throw(rt, err)
}
return v
}

func (*Metrics) XGauge(ctx *context.Context, name string, isTime ...bool) (interface{}, error) {
return newMetric(ctx, name, stats.Gauge, isTime)
// XGauge is a gauge constructor
func (mi *ModuleInstance) XGauge(call goja.ConstructorCall, rt *goja.Runtime) *goja.Object {
v, err := mi.newMetric(call, stats.Gauge)
if err != nil {
common.Throw(rt, err)
}
return v
}

func (*Metrics) XTrend(ctx *context.Context, name string, isTime ...bool) (interface{}, error) {
return newMetric(ctx, name, stats.Trend, isTime)
// XTrend is a trend constructor
func (mi *ModuleInstance) XTrend(call goja.ConstructorCall, rt *goja.Runtime) *goja.Object {
v, err := mi.newMetric(call, stats.Trend)
if err != nil {
common.Throw(rt, err)
}
return v
}

func (*Metrics) XRate(ctx *context.Context, name string, isTime ...bool) (interface{}, error) {
return newMetric(ctx, name, stats.Rate, isTime)
// XRate is a rate constructor
func (mi *ModuleInstance) XRate(call goja.ConstructorCall, rt *goja.Runtime) *goja.Object {
v, err := mi.newMetric(call, stats.Rate)
if err != nil {
common.Throw(rt, err)
}
return v
}
30 changes: 20 additions & 10 deletions js/modules/k6/metrics/metrics_test.go
Original file line number Diff line number Diff line change
@@ -30,6 +30,7 @@ import (
"github.com/stretchr/testify/require"

"go.k6.io/k6/js/common"
"go.k6.io/k6/js/modulestest"
"go.k6.io/k6/lib"
"go.k6.io/k6/stats"
)
@@ -61,11 +62,14 @@ func TestMetrics(t *testing.T) {
t.Parallel()
rt := goja.New()
rt.SetFieldNameMapper(common.FieldNameMapper{})

ctxPtr := new(context.Context)
*ctxPtr = common.WithRuntime(context.Background(), rt)
rt.Set("metrics", common.Bind(rt, New(), ctxPtr))

mii := &modulestest.InstanceCore{
Runtime: rt,
InitEnv: &common.InitEnvironment{},
Ctx: context.Background(),
}
m, ok := New().NewModuleInstance(mii).(*ModuleInstance)
require.True(t, ok)
require.NoError(t, rt.Set("metrics", m.GetExports().Named))
root, _ := lib.NewGroup("", nil)
child, _ := root.Group("child")
samples := make(chan stats.SampleContainer, 1000)
@@ -84,9 +88,10 @@ func TestMetrics(t *testing.T) {
require.NoError(t, err)

t.Run("ExitInit", func(t *testing.T) {
*ctxPtr = lib.WithState(*ctxPtr, state)
mii.State = state
mii.InitEnv = nil
_, err := rt.RunString(fmt.Sprintf(`new metrics.%s("my_metric")`, fn))
assert.EqualError(t, err, "metrics must be declared in the init context at apply (native)")
assert.Contains(t, err.Error(), "metrics must be declared in the init context")
})

groups := map[string]*lib.Group{
@@ -175,9 +180,14 @@ func TestMetricGetName(t *testing.T) {
rt := goja.New()
rt.SetFieldNameMapper(common.FieldNameMapper{})

ctxPtr := new(context.Context)
*ctxPtr = common.WithRuntime(context.Background(), rt)
require.NoError(t, rt.Set("metrics", common.Bind(rt, New(), ctxPtr)))
mii := &modulestest.InstanceCore{
Runtime: rt,
InitEnv: &common.InitEnvironment{},
Ctx: context.Background(),
}
m, ok := New().NewModuleInstance(mii).(*ModuleInstance)
require.True(t, ok)
require.NoError(t, rt.Set("metrics", m.GetExports().Named))
v, err := rt.RunString(`
var m = new metrics.Counter("my_metric")
m.name
122 changes: 99 additions & 23 deletions js/modules/modules.go
Original file line number Diff line number Diff line change
@@ -21,20 +21,16 @@
package modules

import (
"context"
"fmt"
"reflect"
"strings"
"sync"

"go.k6.io/k6/js/modules/k6"
"go.k6.io/k6/js/modules/k6/crypto"
"go.k6.io/k6/js/modules/k6/crypto/x509"
"go.k6.io/k6/js/modules/k6/data"
"go.k6.io/k6/js/modules/k6/encoding"
"go.k6.io/k6/js/modules/k6/grpc"
"go.k6.io/k6/js/modules/k6/html"
"github.com/dop251/goja"
"go.k6.io/k6/js/common"
"go.k6.io/k6/js/modules/k6/http"
"go.k6.io/k6/js/modules/k6/metrics"
"go.k6.io/k6/js/modules/k6/ws"
"go.k6.io/k6/lib"
)

const extPrefix string = "k6/x/"
@@ -69,31 +65,111 @@ type HasModuleInstancePerVU interface {
NewModuleInstancePerVU() interface{}
}

// IsModuleV2 is the interface js modules should implement to get the version 2 of the system
type IsModuleV2 interface {
// NewModuleInstance will get InstanceCore that should provide the module with *everything* it needs and return an
// Instance implementation (embedding the InstanceCore).
// This method will be called for *each* require/import and return an object for VUs.
NewModuleInstance(InstanceCore) Instance
}

// checks that modules implement HasModuleInstancePerVU
// this is done here as otherwise there will be a loop if the module imports this package
var _ HasModuleInstancePerVU = http.New()

// GetJSModules returns a map of all js modules
// GetJSModules returns a map of all registered js modules
func GetJSModules() map[string]interface{} {
result := map[string]interface{}{
"k6": k6.New(),
"k6/crypto": crypto.New(),
"k6/crypto/x509": x509.New(),
"k6/data": data.New(),
"k6/encoding": encoding.New(),
"k6/net/grpc": grpc.New(),
"k6/html": html.New(),
"k6/http": http.New(),
"k6/metrics": metrics.New(),
"k6/ws": ws.New(),
}

mx.Lock()
defer mx.Unlock()
result := make(map[string]interface{}, len(modules))

for name, module := range modules {
result[name] = module
}

return result
}

// Instance is what a module needs to return
type Instance interface {
InstanceCore
GetExports() Exports
}

func getInterfaceMethods() []string {
var t Instance
T := reflect.TypeOf(&t).Elem()
result := make([]string, T.NumMethod())

for i := range result {
result[i] = T.Method(i).Name
}

return result
}

// InstanceCore is something that will be provided to modules and they need to embed it in ModuleInstance
type InstanceCore interface {
GetContext() context.Context

// GetInitEnv returns common.InitEnvironment instance if present
GetInitEnv() *common.InitEnvironment

// GetState returns lib.State if any is present
GetState() *lib.State

// GetRuntime returns the goja.Runtime for the current VU
GetRuntime() *goja.Runtime

// sealing field will help probably with pointing users that they just need to embed this in their Instance
// implementations
}

// Exports is representation of ESM exports of a module
type Exports struct {
// Default is what will be the `default` export of a module
Default interface{}
// Named is the named exports of a module
Named map[string]interface{}
}

// GenerateExports generates an Exports from a module akin to how common.Bind does now.
// it also skips anything that is expected will not want to be exported such as methods and fields coming from
// interfaces defined in this package.
func GenerateExports(v interface{}) Exports {
exports := make(map[string]interface{})
val := reflect.ValueOf(v)
typ := val.Type()
badNames := getInterfaceMethods()
outer:
for i := 0; i < typ.NumMethod(); i++ {
meth := typ.Method(i)
for _, badname := range badNames {
if meth.Name == badname {
continue outer
}
}
name := common.MethodName(typ, meth)

fn := val.Method(i)
exports[name] = fn.Interface()
}

// If v is a pointer, we need to indirect it to access its fields.
if typ.Kind() == reflect.Ptr {
val = val.Elem()
typ = val.Type()
}
var mic InstanceCore // TODO move this out
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.Type == reflect.TypeOf(&mic).Elem() {
continue
}
name := common.FieldName(typ, field)
if name != "" {
exports[name] = val.Field(i).Interface()
}
}
return Exports{Default: exports, Named: exports}
}
60 changes: 60 additions & 0 deletions js/modulestest/modulestest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
*
* k6 - a next-generation load testing tool
* Copyright (C) 2021 Load Impact
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

package modulestest

import (
"context"

"github.com/dop251/goja"
"go.k6.io/k6/js/common"
"go.k6.io/k6/js/modules"
"go.k6.io/k6/lib"
)

var _ modules.InstanceCore = &InstanceCore{}

// InstanceCore is a modules.InstanceCore implementation meant to be used within tests
type InstanceCore struct {
Ctx context.Context
InitEnv *common.InitEnvironment
State *lib.State
Runtime *goja.Runtime
}

// GetContext returns internally set field to conform to modules.InstanceCore interface
func (m *InstanceCore) GetContext() context.Context {
return m.Ctx
}

// GetInitEnv returns internally set field to conform to modules.InstanceCore interface
func (m *InstanceCore) GetInitEnv() *common.InitEnvironment {
return m.InitEnv
}

// GetState returns internally set field to conform to modules.InstanceCore interface
func (m *InstanceCore) GetState() *lib.State {
return m.State
}

// GetRuntime returns internally set field to conform to modules.InstanceCore interface
func (m *InstanceCore) GetRuntime() *goja.Runtime {
return m.Runtime
}

0 comments on commit 34a7743

Please sign in to comment.