АЛЕКСАНДР ФЕФЕЛОВ
Простой интерпретатор командного языка
Я часто сталкиваюсь с необходимостью создания интерпретаторов командных языков и встраивания их в приложения. Под командным языком я понимаю язык, предназначенный для последовательного исполнения операторов вызова команд, описываемых простым синтаксисом:
команда [параметр1 [параметр2 ... [параметрN]]]
Языки подобного рода могут быть использованы:
- для расширения функциональности приложений с помощью скриптов или макросов;
- для удаленного управления приложениями (например, с помощью протокола Telnet);
- для создания интерактивных тестовых программ.
Примерами таких языков являются язык командной строки 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 отражает наиболее общий, неспецифицический тип ошибки. Более специфические ошибки это:
- SyntaxError – синтаксическая ошибка;
- UnknownCommandError – неизвестная команда;
- KnownCommandError – команда уже существует;
- ClassNotFoundError – не найден класс, реализующий команду;
- 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;
}
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();
}
}
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);
sb.append(name + " " + 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();
}
}
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 должна вызываться с двумя обязательными параметрами. Первый – имя, под которым будет зарегистрирована новая команда. Второй – полностью квалифицированное имя класса, реализующего новую команду, причем этот класс должен удовлетворять ряду ограничений:
- класс должен реализовывать интерфейс Command;
- класс должен предоставлять конструктор без параметров;
- класс должен быть доступен для 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;
}
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;
}
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
Facebook
Мой мир
Вконтакте
Одноклассники
Google+
|