Add RunTransactions() approach to application design

This commit is contained in:
Matthew R. Wilson committed 2025-04-13 22:04:54 -07:00
1 parent 7b5603eb11
commit 78aaf72e84
9 files changed
+804 -24

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
// This file is part of https://github.com/racingmars/go3270/
// Copyright 2025 by Matthew R. Wilson, licensed under the MIT license. See
// LICENSE in the project root for license information.
// This example demonstrates using the RunTransactions() approach to
// structuring a go3270 application.
// This file contains a mock in-memory database for the purposes of our
// example application.
package main
import (
"errors"
"sync"
"time"
)
type User struct {
Username string
Password string
Name string
SignupDate time.Time
}
type DB interface {
GetUser(username string) (User, error)
CreateUser(user User) (User, error)
UpdateUser(user User) (User, error)
}
type dbstate struct {
lock sync.Mutex
users map[string]User
}
func Connect() DB {
return &dbstate{
users: make(map[string]User),
}
}
var ErrUserExists = errors.New("username already exists")
var ErrNotFound = errors.New("record not found")
func (db *dbstate) GetUser(username string) (User, error) {
db.lock.Lock()
defer db.lock.Unlock()
user, ok := db.users[username]
if !ok {
return User{}, ErrNotFound
}
return user, nil
}
func (db *dbstate) CreateUser(user User) (User, error) {
db.lock.Lock()
defer db.lock.Unlock()
if _, ok := db.users[user.Username]; ok {
return User{}, ErrUserExists
}
user.SignupDate = time.Now().UTC().Truncate(time.Second)
// Obviously in a real application you wouldn't store plaintext password,
// but this is a simple example for the purposes of demonstrating go3270,
// not demonstrating how to build a secure application.
db.users[user.Username] = user
return user, nil
}
func (db *dbstate) UpdateUser(user User) (User, error) {
db.lock.Lock()
defer db.lock.Unlock()
olduser, ok := db.users[user.Username]
if !ok {
return User{}, ErrNotFound
}
// Make sure original signup date is maintained
user.SignupDate = olduser.SignupDate
// Carry over existing password if we aren't setting a new password
if user.Password == "" {
user.Password = olduser.Password
}
db.users[user.Username] = user
return user, nil
}
+83
View File
@@ -0,0 +1,83 @@
// This file is part of https://github.com/racingmars/go3270/
// Copyright 2025 by Matthew R. Wilson, licensed under the MIT license. See
// LICENSE in the project root for license information.
// This example demonstrates using the RunTransactions() approach to
// structuring a go3270 application.
package main
import (
"fmt"
"net"
"sync"
"github.com/racingmars/go3270"
)
type global struct {
usercount int // number of connected users
countlock sync.Mutex
}
// session is the structure which holds global state for a user session of our
// application. The various Transactions will be methods on this struct.
type session struct {
db DB
user User
// We can also share global state between users
g *global
}
func main() {
// "Connect" to our database (for this example, it's just a mock in-memory
// database).
dbconn := Connect()
gblstate := new(global)
ln, err := net.Listen("tcp", ":3270")
if err != nil {
panic(err)
}
fmt.Println("LISTENING ON PORT 3270 FOR CONNECTIONS")
fmt.Println("Press Ctrl-C to end server.")
for {
conn, err := ln.Accept()
if err != nil {
panic(err)
}
go handle(conn, dbconn, gblstate)
}
}
// handle is the handler for individual user connections.
func handle(conn net.Conn, db DB, gblstate *global) {
defer conn.Close()
// Create the state for this client's connection
state := session{
db: db,
g: gblstate,
}
// Add to the global user counter
gblstate.countlock.Lock()
gblstate.usercount++
gblstate.countlock.Unlock()
// When the session ends, reduce the user count
defer func() {
gblstate.countlock.Lock()
gblstate.usercount--
gblstate.countlock.Unlock()
}()
// Always begin new connection by negotiating the telnet options
go3270.NegotiateTelnet(conn)
err := go3270.RunTransactions(conn, state.login, nil)
if err != nil {
fmt.Println(err)
}
}
+68
View File
@@ -0,0 +1,68 @@
// This file is part of https://github.com/racingmars/go3270/
// Copyright 2025 by Matthew R. Wilson, licensed under the MIT license. See
// LICENSE in the project root for license information.
// This example demonstrates using the RunTransactions() approach to
// structuring a go3270 application.
// This file contains the help transaction generator.
package main
import (
"net"
"github.com/racingmars/go3270"
)
var helpScreen = go3270.Screen{
{Row: 0, Col: 45, Intense: true, Content: "Online Help"},
{Row: 2, Col: 0,
Content: "This help screen is an example of a transaction that"},
{Row: 3, Col: 0,
Content: "can be used as the next transaction from many other transactions"},
{Row: 4, Col: 0, Content: "and returns to the transaction that called it."},
{Row: 6, Col: 0, Content: "Press"},
{Row: 6, Col: 6, Content: "PF3", Color: go3270.White, Intense: true},
{Row: 6, Col: 10,
Content: "to return to the transaction from which you came."},
// Error message
{Row: 21, Col: 0, Name: "errormsg", Color: go3270.Red, Intense: true},
// Key legend
{Row: 23, Col: 1, Content: "F3=Exit"},
}
// The help transaction is an example of returning a closure over a parameter,
// in this case the desired next transaction. This allows us to use the same
// transaction from multiple other transactions and return to the requested
// transaction when the user leaves the help transaction.
//
// Any data passed to help will be passed back to the transaction it returns
// to.
//
// The help transaction has no need for any global or session state, so this
// generator function for it is a stand-alone function and not a method on the
// session.
func help(returnTransaction go3270.Tx) go3270.Tx {
return func(conn net.Conn, data any) (go3270.Tx, any, error) {
_, err := go3270.HandleScreen(
helpScreen, // the screen to display
nil, // (no) rules to enforce
nil, // pre-populated values in fields
[]go3270.AID{go3270.AIDPF3}, // keys we accept
nil,
"errormsg", // name of field to put error messages in
23, 79, // cursor coordinates
conn)
if err != nil {
return nil, nil, err
}
// Any accepted key returns to the requested returnTransaction
return returnTransaction, data, nil
}
}
+257
View File
@@ -0,0 +1,257 @@
// This file is part of https://github.com/racingmars/go3270/
// Copyright 2025 by Matthew R. Wilson, licensed under the MIT license. See
// LICENSE in the project root for license information.
// This example demonstrates using the RunTransactions() approach to
// structuring a go3270 application.
// This file contains the login and user registration transactions.
package main
import (
"net"
"github.com/racingmars/go3270"
)
const loginUsername = "username"
const loginPassword = "password"
const loginPasswordConf = "password confirmation"
const loginName = "name"
const loginErr = "errormsg"
var loginScreen = go3270.Screen{
{Row: 0, Col: 37, Intense: true, Content: "Logon"},
{Row: 2, Col: 0,
Content: "Welcome to the go3270 example application. Please log on."},
// Username
{Row: 4, Col: 0, Content: "Username . . .", Color: go3270.Green},
{Row: 4, Col: 15, Name: loginUsername, Write: true,
Highlighting: go3270.Underscore, Color: go3270.Turquoise},
{Row: 4, Col: 24, Autoskip: true}, // field "stop" character
// Password
{Row: 5, Col: 0, Content: "Password . . .", Color: go3270.Green},
{Row: 5, Col: 15, Name: loginPassword, Write: true, Hidden: true},
{Row: 5, Col: 79}, // field "stop" character
// Registration instructions
{Row: 7, Col: 0, Content: "If you don't yet have an account, press"},
{Row: 7, Col: 40, Content: "PF5", Color: go3270.White, Intense: true},
{Row: 7, Col: 44, Content: "to register a new account."},
// Error message
{Row: 21, Col: 0, Name: loginErr, Color: go3270.Red, Intense: true},
// Key legend
{Row: 23, Col: 1, Content: "F1=Help"},
{Row: 23, Col: 14, Content: "F3=Exit"},
{Row: 23, Col: 27, Content: "F5=Register"},
}
var loginScreenRules = go3270.Rules{
loginUsername: {Validator: go3270.NonBlank},
loginPassword: {Validator: go3270.NonBlank, Reset: true},
}
// login transaction accepts a string value in data if the login screen
// should be initialized with an error message.
func (sess *session) login(conn net.Conn, data any) (go3270.Tx, any, error) {
fieldValues := make(map[string]string)
if data != nil {
if errmsg, ok := data.(string); ok {
fieldValues[loginErr] = errmsg
}
}
resp, err := go3270.HandleScreen(
loginScreen, // the screen to display
loginScreenRules, // the rules to enforce
fieldValues, // pre-populated values in fields
[]go3270.AID{go3270.AIDEnter}, // keys we accept -- validating
[]go3270.AID{ // keys we accept -- non-validating
go3270.AIDPF1,
go3270.AIDPF3,
go3270.AIDPF5,
go3270.AIDClear},
loginErr, // name of field to put error messages in
4, 16, // cursor coordinates
conn)
if err != nil {
return nil, nil, err
}
switch resp.AID {
case go3270.AIDClear:
// re-run the transaction with empty values
return sess.login, nil, nil
case go3270.AIDPF1:
// Display help transaction, then return to this transaction
return help(sess.login), nil, nil
case go3270.AIDPF3:
// User wants to quit; return no next transaction
return nil, nil, nil
case go3270.AIDPF5:
// Send user to registration transaction
return sess.newuser, nil, nil
}
// If we didn't get one of the other allowed keys, try to log the user in.
username := resp.Values[loginUsername]
password := resp.Values[loginPassword]
user, err := sess.db.GetUser(username)
// NOTE: this is just an example application. Obviously in a real
// application, password hashing would be in place.
if err != nil || user.Password != password {
// If we couldn't get the username from the DB, or if the password
// doesn't match, re-run the login transaction with an error
// message displayed.
return sess.login, "Username or password not valid.", nil
}
// Login was successful. Update the session state to the logged-in user.
sess.user = user
return sess.mainmenu, nil, nil
}
var newuserScreen = go3270.Screen{
{Row: 0, Col: 30, Intense: true, Content: "New User Registration"},
{Row: 2, Col: 0,
Content: "Please provide your user registration details."},
// Username
{Row: 4, Col: 0, Content: "Username . . .", Color: go3270.Green},
{Row: 4, Col: 15, Name: loginUsername, Write: true,
Highlighting: go3270.Underscore, Color: go3270.Turquoise},
{Row: 4, Col: 24, Autoskip: true}, // field "stop" character
// Password
{Row: 5, Col: 0, Content: "Password . . .", Color: go3270.Green},
{Row: 5, Col: 15, Name: loginPassword, Write: true, Hidden: true},
{Row: 5, Col: 79, Autoskip: true}, // field "stop" character
// Password
{Row: 6, Col: 0, Content: "Confirm Pass .", Color: go3270.Green},
{Row: 6, Col: 15, Name: loginPasswordConf, Write: true, Hidden: true},
{Row: 6, Col: 79, Autoskip: true}, // field "stop" character
// Name
{Row: 7, Col: 0, Content: "Name . . . . .", Color: go3270.Green},
{Row: 7, Col: 15, Name: loginName, Write: true,
Highlighting: go3270.Underscore, Color: go3270.Turquoise},
{Row: 7, Col: 46, Autoskip: true}, // field "stop" character
// Error message
{Row: 21, Col: 0, Name: loginErr, Color: go3270.Red, Intense: true},
// Key legend
{Row: 23, Col: 1, Content: "F1=Help"},
{Row: 23, Col: 14, Content: "F3=Exit"},
}
var newuserScreenRules = go3270.Rules{
loginUsername: {Validator: go3270.NonBlank},
loginPassword: {Validator: go3270.NonBlank, Reset: true},
loginPasswordConf: {Validator: go3270.NonBlank, Reset: true},
}
// newuserData is the data that can be passed into the newuser transaction
type newuserData struct {
username string
name string
errmsg string
}
func (sess *session) newuser(conn net.Conn, data any) (go3270.Tx, any, error) {
fieldValues := make(map[string]string)
if data != nil {
if newdata, ok := data.(newuserData); ok {
fieldValues[loginUsername] = newdata.username
fieldValues[loginName] = newdata.name
fieldValues[loginErr] = newdata.errmsg
}
}
resp, err := go3270.HandleScreen(
newuserScreen, // the screen to display
newuserScreenRules, // the rules to enforce
fieldValues, // pre-populated values in fields
[]go3270.AID{go3270.AIDEnter}, // keys we accept -- validating
[]go3270.AID{ // keys we accept -- non-validating
go3270.AIDPF1,
go3270.AIDPF3,
go3270.AIDClear,
},
loginErr, // name of field to put error messages in
4, 16, // cursor coordinates
conn)
if err != nil {
return nil, nil, err
}
switch resp.AID {
case go3270.AIDClear:
// Re-run transaction with empty values
return sess.newuser, nil, nil
case go3270.AIDPF1:
// Display help screen, returning to this transaction after
return help(sess.newuser), nil, nil
case go3270.AIDPF3:
// User wants to cancel registration, go back to login screen.
return sess.login, nil, nil
}
// Otherwise, we'll try creating the user.
username := resp.Values[loginUsername]
password := resp.Values[loginPassword]
passwordConf := resp.Values[loginPasswordConf]
name := resp.Values[loginName]
if password != passwordConf {
// Re-run the newuser transaction with an error message
return sess.newuser, newuserData{
username: username,
name: name,
errmsg: "Passwords do not match.",
}, nil
}
user, err := sess.db.CreateUser(User{
Username: username,
Password: password,
Name: name,
})
if err == ErrUserExists {
// Re-run the newuser transaction with an error message
return sess.newuser, newuserData{
username: username,
name: name,
errmsg: "Username already exists; please choose a new one.",
}, nil
}
if err != nil {
return sess.newuser, newuserData{
username: username,
name: name,
errmsg: "Unknown error creating new user.",
}, nil
}
// Success! We'll stick the new user in the session state like login does
// and go to the main menu.
sess.user = user
return sess.mainmenu, nil, nil
}
+232
View File
@@ -0,0 +1,232 @@
// This file is part of https://github.com/racingmars/go3270/
// Copyright 2025 by Matthew R. Wilson, licensed under the MIT license. See
// LICENSE in the project root for license information.
// This example demonstrates using the RunTransactions() approach to
// structuring a go3270 application.
// This file contains the main menu and the example placeholder features
// of the application.
package main
import (
"net"
"strconv"
"strings"
"time"
"github.com/racingmars/go3270"
)
const mainmenuOption = "option"
const mainmenuUsername = "username"
const mainmenuTime = "time"
const mainmenuUsers = "users"
const mainmenuError = "errormsg"
var mainmenuScreen = go3270.Screen{
{Row: 0, Col: 31, Intense: true, Content: "Main Menu"},
// Option
{Row: 1, Col: 0, Content: "Option ===>", Color: go3270.Green},
{Row: 1, Col: 12, Name: mainmenuOption, Write: true,
Highlighting: go3270.Underscore, Color: go3270.Turquoise},
{Row: 1, Col: 79, Autoskip: true}, // field "stop" character
// Info
{Row: 3, Col: 57, Content: "User ID . :", Color: go3270.Green},
{Row: 3, Col: 69, Name: mainmenuUsername, Color: go3270.Turquoise},
{Row: 4, Col: 57, Content: "Time. . . :", Color: go3270.Green},
{Row: 4, Col: 69, Name: mainmenuTime, Color: go3270.Turquoise},
{Row: 5, Col: 57, Content: "Users . . :", Color: go3270.Green},
{Row: 5, Col: 69, Name: mainmenuUsers, Color: go3270.Turquoise},
// Options
{Row: 3, Col: 0, Content: "1", Color: go3270.White},
{Row: 3, Col: 3, Content: "Feature 1",
Color: go3270.Turquoise, Intense: true},
{Row: 3, Col: 17, Content: "A very cool feature", Color: go3270.Green},
{Row: 4, Col: 0, Content: "2", Color: go3270.White},
{Row: 4, Col: 3, Content: "Feature 2",
Color: go3270.Turquoise, Intense: true},
{Row: 4, Col: 17, Content: "Another neat feature", Color: go3270.Green},
{Row: 5, Col: 0, Content: "3", Color: go3270.White},
{Row: 5, Col: 3, Content: "Feature 3",
Color: go3270.Turquoise, Intense: true},
{Row: 5, Col: 17, Content: "This one's a boring feature",
Color: go3270.Green},
{Row: 7, Col: 5, Content: "Enter", Color: go3270.Green},
{Row: 7, Col: 11, Content: "X", Color: go3270.Turquoise, Intense: true},
{Row: 7, Col: 13, Content: "to log off and exit.", Color: go3270.Green},
// Error message
{Row: 21, Col: 0, Name: mainmenuError, Color: go3270.Red, Intense: true},
// Key legend
{Row: 23, Col: 1, Content: "F1=Help"},
{Row: 23, Col: 14, Content: "F3=Exit"},
{Row: 23, Col: 27, Content: "F5=Refresh"},
}
type mainmenuData struct {
option string // pre-populated option field value
errmsg string // error message to display
}
// mainmenu transaction accepts a mainmenuData struct as the data if the
// option field or error message should be populated.
func (sess *session) mainmenu(conn net.Conn, data any) (go3270.Tx, any, error) {
fieldValues := make(map[string]string)
if data != nil {
if d, ok := data.(mainmenuData); ok {
fieldValues[mainmenuOption] = d.option
fieldValues[mainmenuError] = d.errmsg
}
}
fieldValues[mainmenuUsername] = strings.ToUpper(sess.user.Username)
fieldValues[mainmenuTime] = time.Now().UTC().Format("15:04:05")
fieldValues[mainmenuUsers] = strconv.Itoa(sess.g.usercount)
resp, err := go3270.HandleScreen(
mainmenuScreen, // the screen to display
nil, // (no) rules to enforce
fieldValues, // pre-populated values in fields
[]go3270.AID{go3270.AIDEnter}, // keys we accept -- validating
[]go3270.AID{ // keys we accept -- non-validating
go3270.AIDPF1,
go3270.AIDPF3,
go3270.AIDPF5,
},
mainmenuError, // name of field to put error messages in
1, 13, // cursor coordinates
conn)
if err != nil {
return nil, nil, err
}
switch resp.AID {
case go3270.AIDPF1:
// Display help, then return to this transaction
return help(sess.mainmenu), nil, nil
case go3270.AIDPF3:
// Exit
return nil, nil, nil
case go3270.AIDPF5:
// Refresh
return sess.mainmenu, nil, nil
}
// Handle the available options:
switch resp.Values[mainmenuOption] {
case "1":
return sess.exampleFeature,
"This is feature 1, a very cool feature.", nil
case "2":
return sess.exampleFeature,
"This is feature 2, another neat feature.", nil
case "3":
return sess.exampleFeature,
"This is feature 3, which is a boring one.", nil
case "x", "X":
// As if user hit PF3; exit
return nil, nil, nil
case "":
// As if user hit PF5, refresh
return sess.mainmenu, nil, nil
default:
// Run the transaction again with an error message
return sess.mainmenu,
mainmenuData{
option: resp.Values[mainmenuOption],
errmsg: "Unknown option",
}, nil
}
}
const featureMessage = "message"
var exampleScreen = go3270.Screen{
{Row: 0, Col: 30, Intense: true, Content: "Application Feature"},
// Info -- replicate the data shown on the main menu
{Row: 3, Col: 57, Content: "User ID . :", Color: go3270.Green},
{Row: 3, Col: 69, Name: mainmenuUsername, Color: go3270.Turquoise},
{Row: 4, Col: 57, Content: "Time. . . :", Color: go3270.Green},
{Row: 4, Col: 69, Name: mainmenuTime, Color: go3270.Turquoise},
{Row: 5, Col: 57, Content: "Users . . :", Color: go3270.Green},
{Row: 5, Col: 69, Name: mainmenuUsers, Color: go3270.Turquoise},
// Feature-specific message
{Row: 12, Col: 0, Name: featureMessage},
{Row: 14, Col: 0, Content: "Press"},
{Row: 14, Col: 6, Content: "PF3", Intense: true, Color: go3270.White},
{Row: 14, Col: 10, Content: "to return to the main menu."},
// Error message
{Row: 21, Col: 0, Name: mainmenuError, Color: go3270.Red, Intense: true},
// Key legend
{Row: 23, Col: 1, Content: "F1=Help"},
{Row: 23, Col: 14, Content: "F3=Exit"},
{Row: 23, Col: 27, Content: "F5=Refresh"},
}
// exampleFeature is a transaction that will act as a placeholder for real
// application functionality. It accepts a string in the data which will
// be displayed on the panel.
func (sess *session) exampleFeature(conn net.Conn, data any) (go3270.Tx,
any, error) {
fieldValues := make(map[string]string)
if data != nil {
if message, ok := data.(string); ok {
fieldValues[featureMessage] = message
}
}
fieldValues[mainmenuUsername] = strings.ToUpper(sess.user.Username)
fieldValues[mainmenuTime] = time.Now().UTC().Format("15:04:05")
fieldValues[mainmenuUsers] = strconv.Itoa(sess.g.usercount)
resp, err := go3270.HandleScreen(
exampleScreen, // the screen to display
nil, // (no) rules to enforce
fieldValues, // pre-populated values in fields
nil, // keys we accept -- validating
[]go3270.AID{ // keys we accept -- non-validating
go3270.AIDPF1,
go3270.AIDPF3,
go3270.AIDPF5,
},
mainmenuError, // name of field to put error messages in
23, 79, // cursor coordinates
conn)
if err != nil {
return nil, nil, err
}
switch resp.AID {
case go3270.AIDPF1:
// Display help, then return to this transaction
return help(sess.exampleFeature), data, nil
case go3270.AIDPF3:
// Exit
return sess.mainmenu, nil, nil
case go3270.AIDPF5:
// Refresh
return sess.exampleFeature, data, nil
}
// ...there shouldn't be any actions not handled above. Just in case,
// we'll just re-run this transaction as if refresh was hit.
return sess.exampleFeature, data, nil
}