Александр Фефелов
Я часто сталкиваюсь с необходимостью создания интерпретаторов командных
языков и встраивания их в приложения. Под командным языком я понимаю язык,
предназначенный для последовательного исполнения операторов вызова команд,
описываемых простым синтаксисом:
команда [параметр1 [параметр2
... [параметрN]]]
Языки подобного рода могут быть
использованы:
n для расширения функциональности приложений с
помощью скриптов или макросов;
n для удаленного управления приложениями
(например, с помощью протокола Telnet);
n для создания интерактивных тестовых программ.
Примерами таких языков являются язык командной
строки ftp-клиента или языки управления столь распространенными сейчас
DSL-модемами.
В этой статье я предложу простое решение, которое
позволит вам с минимальными трудозатратами создать интерпретатор для командного
языка. В качестве языка программирования я буду использовать Java.
Intro
Итак, имеется некоторый язык (далее будем называть его целевым), поддерживающий
только один оператор – оператор вызова команды. Программы на этом языке
записываются в виде последовательности строк, причем в одной строке может быть
расположен только один оператор. Система команд целевого языка полностью
определена решаемой задачей.
Необходимо для этого языка создать интерпретатор,
который, получив очередной оператор, будет выполнять его и возвращать результат
выполнения.
Возможно, результат выполнения текущего оператора
будет зависеть от результатов выполнения предыдущих операторов или от состояния
приложения, в которое встроен интерпретатор. Например, в том же ftp-клиенте
нельзя получить файл командой get, если перед этим не было установлено
соединение с сервером. Поэтому, интерпретатор должен поддерживать понятие
контекста исполнения, причем одним из результатов выполнения оператора может
быть изменение этого контекста.
Ошибки
Начнем с ошибок. Для обработки ошибок, возникающих в процессе работы
интерпретатора, создадим несложную иерархию исключений. Корнем этой иерархии
будет исключение CliError:
package simplecli.error;
public class CliError extends
Exception {
public CliError() {
}
public CliError(String msg)
{
super(msg);
}
}
Все остальные исключения нашей иерахии являются
наследниками CliError и реализуются аналогичным образом.
CliError отражает наиболее общий,
неспецифицический тип ошибки. Более специфические ошибки это:
n SyntaxError – синтаксическая ошибка;
n UnknownCommandError – неизвестная команда;
n KnownCommandError – команда уже существует;
n ClassNotFoundError – не найден класс,
реализующий команду;
n ClassCastError – класс не может реализовывать
команду.
Команды
У всех команд целевого языка, как бы ни различались алгоритмы их работы,
есть одно общее важное свойство – способность к выполнению с определенным
набором параметров в определенном контексте. Отразим это в интерфейсе Command:
package simplecli.command;
import java.util.*;
import simplecli.error.*;
public interface Command {
String run(Properties
context, ArrayList parameters)
throws CliError;
Для выполнения команды интерпретатор вызывает
метод run, передавая ему экземпляр ArrayList, содержащий в виде объектов String
параметры вызова команды, и экземпляр Properties, отражающий текущее состояние
контекста выполнения (см. ниже). Результат выполнения команды возвращается в
виде строки.
В реальном целевом языке хотелось бы иметь
поддержку системы помощи, хотя бы и в минимальном объеме. Это пригодится в
интерактивном режиме работы. Поэтому добавим в интерфейс Command пару методов:
String getDescription(String
name);
String getHelp(String
name);
}
Метод getDescription должен возвращать краткое
описание команды, а метод getHelp – подробную справочную информацию. Оба метода
в качестве параметра получают имя, под которым команда зарегистрирована в
целевом языке.
Абстрактный интерпретатор
Разобравшись с ошибками и командами, мы можем приступить к разработке ядра
нашего интерпретатора.
Структуры данных
Первым делом разберемся, как интерпретатор будет хранить данные, отражающие
его состояние, – систему команд и контекст выполнения.
Система команд, с точки зрения нашего
интерпретатора, – это набор пар «имя-класс», причем имена обязаны быть
уникальными, а вот классы – нет. Для реализации такой структуры идеально
подходит Hashtable. Им и воспользуемся.
Как хранить контекст выполнения? Для
практического применения может оказаться вполне достаточной реализация понятия
переменных окружения (environment variables), используемого в командных
процессорах, как в UNIX, так и в DOS/Windows. Для хранения контекста также
подходит Hashtable, но, поскольку и имена, и значения переменных есть строки,
удобнее будет использование Properties.
package simplecli;
import java.io.*;
import java.util.*;
import simplecli.command.*;
import simplecli.error.*;
abstract public class
Interpreter {
private Hashtable commands;
private Properties context;
public Hashtable
getCommands() {
return commands;
}
public Properties
getContext() {
return context;
}
При разбиении интерпретируемой строки на
лексические элементы интерпретатор использует разделитель. Полезно будет
оформить этот разделитель как полноценный член класса:
private String delimiter;
public void
setDelimiter(String delimiter) {
this.delimiter =
delimiter;
}
public String getDelimiter()
{
return delimiter;
}
Инициализация
Инициализация интерпретатора, включающая в себя определение системы команд и
начальную настройку контекста выполнения, зависит, конечно же, от системы, в
которую интерпретатор будет встроен. Поэтому отдадим инициализацию на откуп
абстрактным методам:
public Interpreter() {
context = new
Properties();
initContext(context);
commands = new
Hashtable();
initCommands(commands);
setDelimiter("
");
}
abstract public void
initContext(Properties context);
abstract public void
initCommands(Hashtable commands);
Режимы работы
В каких режимах должен работать наш интерпретатор? В первую очередь это
выполнение программы, записанной в некотором файле. В более общем случае
программа для выполнения выбирается интерпретатором из потока. Потоком может
быть и файл, и консольный ввод, и сетевое соединение. Назовем такой режим
работы потоковым. В потоковом режиме интерпретатор получает из потока очередную
строку и выполняет ее.
Выделим в особый случай интерпретацию отдельных
строк. Например, в графическом интерфейсе пользователя какому-либо пункту меню
может быть сопоставлена некоторая строка, которая и передается интерпретатору
при выборе пользователем этого пункта меню. Назовем выполнение интепретатором
отдельных строк строковым режимом работы.
Очевидно, что реализация потоковой интерпретации
может быть основана на возможностях строкового режима. Поэтому начнем мы именно
со строковой интерпретации:
public String
interpretClause(String clause)
throws
UnknownCommandError, CliError {
if (clause == null) {
return null;
}
clause = clause.trim();
if (clause.length() == 0)
{
return null;
}
String[] tokens =
tokenize(clause, getDelimiter());
ArrayList parameters =
new ArrayList();
for (int i = 1; i <
tokens.length; i++) {
parameters.add(tokens[i]);
}
Command command =
(Command) commands.get(tokens[0]);
if (command == null) {
throw new
UnknownCommandError(tokens[0]);
}
return command.run(getContext(),
parameters);
}
public String[]
tokenize(String s, String d) {
return s.split(d);
}
Реализовав строковый режим, мы легко можем
реализовать и потоковый:
public void
interpret(BufferedReader in,
PrintWriter out, PrintWriter
err)
throws IOException {
while (true) {
String prompt =
context.getProperty("$PROMPT");
if (prompt != null
&& prompt.length() > 0) {
out.print(prompt);
out.flush();
}
String clause =
in.readLine();
if (clause == null) {
break;
}
String result = null;
try {
result =
interpretClause(clause);
} catch (CliError ce) {
err.println(ce);
if
(mustStopOnError()) {
break;
}
}
if (result != null) {
out.println(result);
}
if (mustQuit()) {
break;
}
}
}
public boolean mustQuit() {
String quit =
context.getProperty("$QUIT");
if (quit == null) {
return false;
}
if
(quit.equals("yes")
||
quit.equals("true")) {
return true;
} else {
return false;
}
}
public boolean
mustStopOnError() {
String quit =
context.getProperty("$STOPONERROR");
if (quit == null) {
return true;
}
if
(quit.equals("yes")
||
quit.equals("true")) {
return true;
} else {
return false;
}
}
Отметим пару особенностей потокового режима
работы.
Во-первых, каким образом интепретатор узнает о
необходимости завершения интерпретации? Если программа для выполнения
считывается из файла, то, очевидно, конец файла и служит сигналом
интерпретатору. Но что, если строки программы поступают с консоли?
Во-вторых, что должен делать интерпретатор при
обнаружении ошибки – прервать выполнение или продолжить его со следующего
оператора?
Обе проблемы просто решаются с использованием
контекста выполнения. Заведем в контексте специальные переменные (назовем их
соответственно $QUIT и $STOPON-ERROR) и заставим интерпретатор проверять их
значения после выполнения очередного оператора.
Аналогичным образом можно решить задачу выдачи
приглашения ко вводу, если ввод осуществляется с консоли. Если контекстная
переменная $PROMPT что-то содержит, то это «что-то» и будет использовано в
качестве приглашения.
Конкретный интерпретатор
Описанный абстрактный интерпретатор все еще не может быть непосредственно
встроен в какую-либо систему. Необходимо конкретизировать методы инициализации.
В качестве примера создадим конкретный
интепретатор и реализуем для него простую, но мощную систему команд.
package simplecli;
import java.util.*;
import simplecli.command.*;
public class BaseInterpreter
extends Interpreter {
public void
initContext(Properties context) {
context.setProperty("$QUIT", "false");
context.setProperty("$STOPONERROR",
"false");
context.setProperty("$PROMPT", ">");
}
public void
initCommands(Hashtable commands) {
commands.put("exit", new ExitCommand());
commands.put("quit", new ExitCommand());
commands.put("bye", new ExitCommand());
commands.put("help", new HelpCommand(this));
commands.put("?", new HelpCommand(this));
commands.put("set", new SetCommand());
commands.put("register", new RegisterCommand(this));
commands.put("unregister", new UnregisterCommand(this));
}
}
Как видите – ничего сложного. В контексте
создаются описанные выше специальные переменные, а в системе команд
регистрируются несколько команд, о реализации которых мы и поговорим ниже.
Самая простая команда – команда завершения
интерпретации ExitCommand (здесь и далее я называю команды по именам классов,
их реализующих). Эта команда просто меняет значение контекстной переменной
$QUIT:
package simplecli.command;
import java.util.*;
import simplecli.error.*;
public class ExitCommand implements
Command {
public String
run(Properties context,
ArrayList parameters)
throws SyntaxError {
if (parameters.size()
> 0) {
throw new
SyntaxError();
}
context.setProperty("$QUIT", "yes");
return null;
}
public String
getDescription(String name) {
return "Exits
current interpreter session.";
}
public String
getHelp(String name) {
return "Exits
current interpreter session."
+ CRLF + "Syntax:
" + name;
}
private final
static String CRLF =
System.getProperty("line.separator");
}
Управление контекстом
выполнения
Управляя контекстом выполнения, команды могут изменять состояние программы,
в которую встроен интерпретатор, или влиять на выполнение других команд.
Для управления контекстом мы реализуем команду
SetCommand, интерфейс которой схож с интерфейсом команды SET командных
процессоров bash и CMD.EXE.
Вызов SetCommand без параметров будет возвращать
список пар «переменная=значение» для всех переменных, определенных в контексте.
Вызов с одним параметром удалит из контекста
переменную, заданную параметром.
Вызов с двумя параметрами установит переменную,
указанную первым параметром, в значение, заданное вторым параметром.
package simplecli.command;
import java.util.*;
import simplecli.error.*;
public class SetCommand
implements Command {
public String
run(Properties context,
ArrayList parameters)
throws SyntaxError {
switch
(parameters.size()) {
case 0:
// Нет параметров -
возвращаем список всех
// переменных в контексте
выполнения.
StringBuffer sb = new
StringBuffer();
Enumeration names =
context.propertyNames();
while
(names.hasMoreElements()) {
String name =
(String) names.nextElement();
String val =
context.getProperty(name);
sb.append(name +
"=" + val);
if
(names.hasMoreElements()) {
sb.append(CRLF);
}
}
if (sb.length() >
0) {
return
sb.toString();
} else {
return null;
}
case 1:
// Один параметр -
удаляем из контекста выполнения
// переменную,
указанную в качестве параметра.
context.remove((String) parameters.get(0));
return null;
case 2:
// Два параметра -
устанавливаем в контексте
// выполнения
переменную, указанную в качестве
// первого параметра,
в значение, указанное
// в качестве второго
параметра.
context.setProperty((String)
parameters.get(0),
(String)
parameters.get(1));
return null;
default:
throw new
SyntaxError();
}
}
public String
getDescription(String name) {
return "Manages
environment variables.";
}
public String
getHelp(String name) {
return "Manages
environment variables."
+ CRLF + "Syntax:
" + name + " [variable [value]]";
}
private final static String
CRLF =
System.getProperty("line.separator");
}
Побочным эффектом изменения контекста может быть
влияние на процесс интерпретации. Например, описанная выше команда завершения
интерпретации может быть реализована непосредственно на целевом языке:
set $QUIT true
Система помощи
Интерфейс Command предоставляет возможности для создания справочной системы.
На основе этих возможностей мы и запрограммируем команду HelpCommand. Вызов
команды HelpCommand без параметров возвратит список всех зарегистрированных
команд с кратким описанием каждой из них, а вызов с одним параметром выдаст
подробную справку по команде, имя которой задано параметром.
package simplecli.command;
import java.util.*;
import simplecli.*;
import simplecli.error.*;
public class HelpCommand
implements Command {
public
HelpCommand(Interpreter interpreter) {
this.interpreter =
interpreter;
}
public String
run(Properties context,
ArrayList parameters)
throws SyntaxError,
UnknownCommandError {
switch
(parameters.size()) {
case 0: {
// Нет параметров -
возвращаем краткое описание
// всех известных
команд.
StringBuffer sb = new
StringBuffer();
Hashtable commands =
interpreter.getCommands();
Enumeration names =
commands.keys();
while
(names.hasMoreElements()) {
String name =
(String) names.nextElement();
Command cmd =
(Command) commands.get(name);
String descr =
cmd.getDescription(name);
sb.append(name +
"\t" + descr);
if
(names.hasMoreElements()) {
sb.append(CRLF);
}
}
if (sb.length() >
0) {
return
sb.toString();
} else {
return null;
}
}
case 1: {
// Один параметр -
возвращаем полную справочную
// информацию по
команде, указанной в качестве
// параметра.
String name =
(String) parameters.get(0);
Hashtable commands =
interpreter.getCommands();
Command cmd =
(Command) commands.get(name);
if (cmd == null) {
throw new
UnknownCommandError(name);
}
return
cmd.getHelp(name);
}
default:
throw new
SyntaxError();
}
}
public String
getDescription(String name) {
return "Prints help
infomation.";
}
public String
getHelp(String name) {
return "Prints help
infomation."
+ CRLF + "Syntax:
" + name + " [command]";
}
private Interpreter
interpreter;
private final static String
CRLF =
System.getProperty("line.separator");
}
Динамическое изменение системы
команд
Посмотрев на код нашего конкретного интерпретатора, можно заметить, что
система команд целевого языка фиксируется еще на этапе компиляции кода. Это не
очень гибкое решение.
Конечно же, можно переписать метод initCommands
так, чтобы он создавал систему команд «на лету», считывая ее описание из
конфигурационного файла.
Но более интересным решением может оказаться
встраивание возможности изменения системы команд непосредственно в целевой
язык.
Давайте закодируем пару команд
RegisterCommand/UnregisterCommand, первая из которых будет добавлять команду к
системе команд, а вторая – удалять ее оттуда.
Команда RegisterCommand должна
вызываться с двумя обязательными параметрами. Первый – имя, под которым будет
зарегистрирована новая команда. Второй – полностью квалифицированное имя
класса, реализующего новую команду, причем этот класс должен удовлетворять ряду
ограничений:
n класс должен реализовывать интерфейс Command;
n класс должен предоставлять конструктор без
параметров;
n класс должен быть доступен для Java VM через
CLASSPATH.
package simplecli.command;
import java.util.*;
import simplecli.*;
import simplecli.error.*;
public class RegisterCommand
implements Command {
public
RegisterCommand(Interpreter interpreter) {
this.interpreter =
interpreter;
}
public String
run(Properties context,
ArrayList parameters)
throws SyntaxError,
KnownCommandError,
ClassNotFoundError, ClassCastError {
if (parameters.size() !=
2) {
throw new
SyntaxError();
}
String name = (String)
parameters.get(0);
String clazzName =
(String) parameters.get(1);
Hashtable commands =
interpreter.getCommands();
if
(commands.containsKey(name)) {
throw new
KnownCommandError();
}
Class clazz = null;
try {
clazz =
Class.forName(clazzName);
} catch
(ClassNotFoundException cnfe) {
throw new
ClassNotFoundError();
}
Command command = null;
try {
command = (Command)
clazz.newInstance();
} catch (Exception e) {
throw new
ClassCastError();
}
commands.put(name,
command);
return null;
}
public String
getDescription(String name) {
return "Registers
new command.";
}
public String
getHelp(String name) {
return "Registers
new command."
+ CRLF + "Syntax:
" + name + " command class";
}
private Interpreter
interpreter;
private final static String
CRLF =
System.getProperty("line.separator");
}
У команды UnregisterCommand всего один параметр,
который задает команду, подлежащую удалению из системы команд.
package simplecli.command;
import java.util.*;
import simplecli.*;
import simplecli.error.*;
public class UnregisterCommand
implements Command {
public
UnregisterCommand(Interpreter interpreter) {
this.interpreter =
interpreter;
}
public String
run(Properties context,
ArrayList parameters)
throws SyntaxError,
UnknownCommandError {
if (parameters.size() !=
1) {
throw new
SyntaxError();
}
String name = (String)
parameters.get(0);
Hashtable commands =
interpreter.getCommands();
if (commands.remove(name)
== null) {
throw new
UnknownCommandError();
}
return null;
}
public String
getDescription(String name) {
return "Unregisters
command.";
}
public String
getHelp(String name) {
return "Unregisters
command."
+ CRLF + "Syntax:
" + name + " command";
}
private Interpreter
interpreter;
private final static String
CRLF =
System.getProperty("line.separator");
}
Испытательный полигон
Вот все работы и завершены. Теперь самое время увидеть интерпретатор в
действии.
package simpleclitest;
import java.io.*;
import simplecli.*;
public class
BaseInterpreterTest {
public static void
main(String[] args)
throws IOException {
System.out.println("BaseInterpreter testbed");
BufferedReader in = new
BufferedReader(
new
InputStreamReader(System.in));
PrintWriter out = new
PrintWriter(System.out, true);
Interpreter interpreter =
new BaseInterpreter();
interpreter.interpret(in,
out, out);
}
}
Компилируем, запускаем, экспериментируем:
BaseInterpreter testbed
>help
bye Exits
current interpreter session.
quit Exits
current interpreter session.
? Prints
help infomation.
help Prints
help infomation.
exit Exits
current interpreter session.
unregister
Unregisters command.
register
Registers new command.
set Manages
environment variables.
>help set
Manages
environment variables.
Syntax: set
[variable [value]]
Поиграем с командой set:
>set
$PROMPT=>
$QUIT=false
$STOPONERROR=false
>set $PROMPT #
#set VAR val
#set
$PROMPT=#
VAR=val
$QUIT=false
$STOPONERROR=false
#set VAR
#set
$PROMPT=#
$QUIT=false
$STOPONERROR=false
Удалим set из системы команд:
#unregister set
#set
simplecli.error.UnknownCommandError:
set
Вернем set обратно:
#register set
simplecli.command.SetCommand
Зарегистрировать уже существующую команду не
удастся:
#register set simplecli.command.SetCommand
simplecli.error.KnownCommandError
Также не удастся зарегистрировать команды,
реализуемые несуществующими или неподходящими классами:
#register wrong
String
simplecli.error.ClassNotFoundError
#register wrong
java.lang.String
simplecli.error.ClassCastError
Ну и напоследок:
#bye