Salesforce上でApexではないサーバーサイドなオレオレ言語を動かしてみます。
成果物はこちら
実装方法
Apexは文字列操作や制御構文などパースに必要な機能は一通り揃っているので、一般的なパーサを実装してASTを使って処理を実行していけばオレオレ言語を実装可能です。 バイナリ操作のメソッドが充実しておらずバイトコード的な実装は厳しいため、逐次コード文字列を読み取ってインタープリター的に実行するアプローチを取りました。
一般的なパーサ実装はこんな感じです↓
- トークナイズ
- パースしてAST生成
- ASTを使ってコードを実行
実装言語の仕様
こんな感じな構文を動かせるようにします
# 動的に変数定義可能
a = 123
# 関数呼び出し
puts "hello"
# for文
for i = 0; i < 10; i = i + 1 {
puts i
}
# while文
i = 0
while i < 3 {
puts i
i = i + 1
}
# if文
if a == 123 {
puts "a"
} else {
puts "b"
}
トークナイズ(Lexer実装)
こんな感じで実装できます
public class Lexer {
public String str;
public Integer index;
public List<String> reserved = new List<String>{
'if',
'else',
'for',
'while',
'true',
'false'
};
public Lexer(String str) {
this.str = str;
this.index = 0;
}
public List<Token> parse() {
List<Token> tokens = new List<Token>();
String current = this.current();
while (true){
Token token;
switch on current {
when '+', '-', '*', '/', '=', '\n', '(', ')', '{', '}', '!', ';', '>', '<' {
if (current == '=' || current == '!' || current == '<' || current == '>') {
if (this.peek() == '=') {
String type = current + this.peek();
token = new Token(type, type);
this.next();
}
}
if (token == null) {
token = new Token(current, current);
}
}
when else {
if (Pattern.matches('[0-9]', current)) {
token = this.parseInt();
} else if (Pattern.matches('[a-zA-Z]', current)) {
token = this.parseIdent();
}
}
}
if (token != null) {
tokens.add(token);
}
if (this.index == this.str.length() - 1) {
break;
}
current = this.next();
}
return tokens;
}
public String peek() {
return this.str.substring(this.index+1, this.index+2);
}
public String current() {
return this.str.substring(this.index, this.index+1);
}
public String next() {
this.index++;
return this.current();
}
// ...
受け取った文字列を1文字ずつ見ていってトークンの配列を生成します。 (Lexerを書いたことない人が見ると難しそうに見えたりしますが、ここは見た目ほど複雑じゃないただただ泥臭いコードです)
パースしてAST生成
Lexerで生成したトークンをもとにASTを生成します。 Lexerが1文字ずつ見ていったのに対して、Parserは1トークンずつ見ていくことになります。 ASTも単にオブジェクトの入れ子で今回の場合は文の集合が一番上のノードになります。
public class Parser {
public List<Token> tokens;
public Integer index;
public Parser(List<Token> tokens) {
this.tokens = tokens;
this.index = 0;
}
public List<Node> parse() {
return this.statements();
}
public List<Node> statements() {
List<Node> statements = new List<Node>();
while (true){
Node stmt = this.statement();
if (stmt == null) {
break;
}
statements.add(stmt);
this.consume('\n');
}
return statements;
}
public Node statement() {
if (this.current('if') != null) {
return this.ifStatement();
}
if (this.current('while') != null) {
return this.whileStatement();
}
if (this.current('for') != null) {
return this.forStatement();
}
if (this.peek('=') != null) {
return this.assign();
}
return this.call();
}
public Node assign() {
Token ident = this.consume('Ident');
if (ident == null) {
return null;
}
Token op = this.consume('=');
if (op == null) {
return null;
}
Node exp = this.add();
return new AssignNode(ident.value, exp);
}
public Node add() {
Node exp = this.mul();
Token op = this.consume('+');
if (op != null) {
return new BinaryOperatorNode('+', exp, this.add());
}
op = consume('-');
if (op != null) {
return new BinaryOperatorNode('-', exp, this.add());
}
return exp;
}
public Node mul() {
Node exp = this.term();
Token op = this.consume('*');
if (op != null) {
return new BinaryOperatorNode('*', exp, this.mul());
}
op = this.consume('/');
if (op != null) {
return new BinaryOperatorNode('/', exp, this.mul());
}
return exp;
}
public Node term() {
Token exp = this.consume('Integer');
if (exp != null) {
return new IntegerNode(exp.value);
}
exp = this.consume('Ident');
if (exp != null) {
return new IdentifierNode(exp.value);
}
return null;
}
public Token consume(String type) {
Token currentToken = this.current();
if (currentToken == null) {
return null;
}
if (currentToken.type == type) {
this.index++;
return currentToken;
}
return null;
}
public Token peek() {
if (this.tokens.size() <= this.index+1) {
return null;
}
return this.tokens[this.index+1];
}
public Token current() {
if (this.tokens.size() == this.index) {
return null;
}
return this.tokens[this.index];
}
// ...
ASTを使ってコードを実行
Visitorパターンで実装していきます。各ノードにはacceptメソッドを実装させます。
public class BinaryOperatorNode implements Node {
public String type;
public Node left;
public Node right;
public BinaryOperatorNode(String type, Node left, Node right) {
this.type = type;
this.left = left;
this.right = right;
}
public Value accept(Visitor v) {
return v.visitBinaryOperator(this);
}
}
Visitorはこんな感じで実装すればOK
public class Visitor {
public Map<String, Value> variables;
private String buffer;
public Visitor() {
this.variables = new Map<String, Value>();
}
public void run(String str) {
this.buffer = '';
List<Token> tokens = new Lexer(str).parse();
List<Node> nodes = new Parser(tokens).parse();
for (Node node : nodes) {
this.visit(node);
}
}
public String getBuffer() {
return this.buffer;
}
public void visit(Node node) {
Value result = node.accept(this);
}
public Value visitInteger(IntegerNode node) {
return new Value(node.value, node);
}
public Value visitString(StringNode node) {
return new Value(node.value, node);
}
// ...
あとはこんな感じで書けば動きます。
String code = '';
code += 'a = "hello"\n';
code += 'puts a + " apex!"';
Visitor v = new Visitor();
v.run(code);
System.debug(v.getBuffer());
putsで出力した内容は#getBuffer
で受け取れるようにしました。
オレオレ言語なのでputs
以外のチャネルを用意することも可能です。
上記サンプルだとVisitorは変数の状態も保存しているので、例えばプログラムを実行したあとに変数a
の値をVFページに出す、ということも可能です。
所感
動的型付け言語でライトにアプリケーションを記述したい場合には有用かもしれません。 ガッツリやる場合はオブジェクトレコードのロングテキストエリアにプログラムを記述してそれを読み込むような方式になると思いますが、デプロイする必要がないという点においてもApexより手軽です。
また、設定のDSLとしての可能性もありそうです。
例えばカスタム設定のフィールドやJSON/YAML/XMLなどのデータはそれ自身では設定値を条件分岐させることができません。
かといってApexにロジックを書く場合はデプロイが必要になりますし、数式はApexよりは表現力に劣ります。
その点においてミニ言語によるDSLは分岐もループもできるため、設定値としての柔軟性は上がると思います。