From 34a7743fb976a080db2c00ed1a817dc690aab192 Mon Sep 17 00:00:00 2001 From: Mihail Stoykov Date: Wed, 25 Aug 2021 17:47:48 +0300 Subject: [PATCH] common.bind replacement (#2108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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ć --- js/initcontext.go | 88 ++++++++++++++++++- js/modules/k6/metrics/metrics.go | 121 +++++++++++++++++-------- js/modules/k6/metrics/metrics_test.go | 30 ++++--- js/modules/modules.go | 122 +++++++++++++++++++++----- js/modulestest/modulestest.go | 60 +++++++++++++ 5 files changed, 352 insertions(+), 69 deletions(-) create mode 100644 js/modulestest/modulestest.go diff --git a/js/initcontext.go b/js/initcontext.go index ca443566468..e70db8b52bb 100644 --- a/js/initcontext.go +++ b/js/initcontext.go @@ -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 +} diff --git a/js/modules/k6/metrics/metrics.go b/js/modules/k6/metrics/metrics.go index d950eb5ea74..e9daa0cf4ba 100644 --- a/js/modules/k6/metrics/metrics.go +++ b/js/modules/k6/metrics/metrics.go @@ -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 } diff --git a/js/modules/k6/metrics/metrics_test.go b/js/modules/k6/metrics/metrics_test.go index 28d20de48bf..e2a69ca12a9 100644 --- a/js/modules/k6/metrics/metrics_test.go +++ b/js/modules/k6/metrics/metrics_test.go @@ -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 diff --git a/js/modules/modules.go b/js/modules/modules.go index c7c27ff0450..5c940b07e17 100644 --- a/js/modules/modules.go +++ b/js/modules/modules.go @@ -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,27 +65,23 @@ 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 @@ -97,3 +89,87 @@ func GetJSModules() map[string]interface{} { 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} +} diff --git a/js/modulestest/modulestest.go b/js/modulestest/modulestest.go new file mode 100644 index 00000000000..e00cae7c7df --- /dev/null +++ b/js/modulestest/modulestest.go @@ -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 . + * + */ + +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 +}