Содержание
В начале прошлого года мне пришла в голову идея написать собственный язык интерфейсов (IDL), который был бы похож на Protobuf или Thrift, но предназначался бы для веба. Я надеялся закончить его где-нибудь месяца за три. До первой стабильной версии прошло чуть больше года.
Pdef (пидеф, protocol definition language) — это статически типизированный язык описания интерфейсов, который поддерживает JSON и HTTP RPC. Он позволяет один раз описать интерфейсы и структуры данных, а потом сгенерировать код для конкретных языков программирования. Пидеф подходит для публичных апи, внутренних сервисов, распределенных систем, конфигурационных файлов, как формат для хранения данных, кеша и очередей сообщений.
Основная функциональность:
- Развитая система пакетов, модулей и пространств имен.
- Поддержка циклических импортов и зависимостей типов (с некоторыми ограничениями).
- Простая система типов, основанная на четком разделении интерфейсов и структур данных.
- Наследование сообщений (аналог struct’ов) и интерфейсов.
- Поддержка цепочек вызовов, например,
github.user(1).repos().all()
. - JSON как формат данных и HTTP RPC для передачи данных.
- Возможность использовать другие форматы и RPC.
- Подключаемые кодогенераторы (официально поддерживаются Java, Python и Objective-C).
- Опциональность кодогенерации, т.е. Пидеф позволяет сериализовать данные и отправлять запросы руками.
Зачем нужен Пидеф? В первую очередь для повышения производительности труда и упрощения разработки и поддержки клиент-серверного, сервисно-ориентированного и распределенного кода. Но он также объединяет документацию и описание апи и позволяет строить вертикально-интегрированные системы, в которых снижены накладные расходы на взаимодествие отдельных компонентов.
Пример описания сообщения:
message Human {
id int64;
name string;
birthday datetime;
sex Sex;
continent ContinentName;
}
Примеры использования (ссылка на примеры сгенерированного кода есть в конце статьи):
Json
{
"id": 1,
"name": "Ivan Korobkov",
"birthday": "1987-08-07T00:00Z",
"sex": "male",
"continent": "europe"
}
Java
Human human = new Human()
.setId(1)
.setName("John")
.setSex(Sex.MALE)
.setContinent(ContinentName.ASIA)
String json = human.toJson();
Human another = Human.fromJson(json);
Python
human = Human(id=1, name="John")
human.birthday = datetime.datetime(1900, 1, 2)
s = human.to_json()
another = Human.from_json(s)
Objective-C
Human *human = [[Human alloc]init];
human.id = 1;
human.name = @"John";
human.sex = Sex_MALE;
human.continent = ContinentName_EUROPE;
NSError *error = nil;
NSData *data = [human toJsonError:&error];
Human *another = [Human messageWithData:data error:&error];
Установка
Пидеф состоит из компилятора, подключаемых кодогенераторов и байндингов, специфичных для конкретных языков программирования. Компилятор и кодогенераторы написаны на Питоне.
Установка компилятора как пакета Питона с PyPI:
pip install pdef-compiler
Либо можно скачать архив с конкретной версией со страницы релизов проекта, разархивировать его и выполнить:
python setup.py install
Установка кодогенераторов (ссылки для скачивания есть на страницах конкретных языков):
pip install pdef-java
pip install pdef-python
pip install pdef-objc
Все, компилятор готов к использованию. Можно выполнить следующую команду, чтобы убедиться, что все установлено правильно. Она скачает пример пакета и проверит его.
pdefc -v check https://raw.github.com/pdef/pdef/master/example/world.yaml
Каждый кодогенератор при установке добавляет свои команды к компилятору, посмотреть их можно в помощи:
pdefc -h
...
generate-python Python code generator.
generate-objc Objective-C code generator.
generate-java Java code generator.
pdefc generate-python -h
Использование
Создайте файл пакета myproject.yaml
:
package:
name: myproject
modules:
- posts
- photos
Создайте файлы модулей:
// Файл posts.pdef
namespace myproject;
import myproject.photos;
interface Posts {
get(id int64) Post;
@post
create(title string @post, text string @post) Post;
}
message Post {
id int64;
title string;
text string;
photos list<Photo>;
}
// Файл photos.pdef
namespace myproject;
message Photo {
id int64;
url string;
}
Запустите генерацию кода:
pdefc generate-java myproject.yaml --out generated-java/
pdefc generate-python myproject.yaml --out generated-python/
pdefc generate-objc myproject.yaml --out generated-objc/
Кодогенераторы поддерживают сопоставление модулей и пространств имен Пидефа и конкретных языков программирования. Подробнее можно узнать из описания команд.
Руководство по Пидефу 1.1
Синтаксис
Синтаксис Пидефа похож на Java/C++ с инвертированным порядком типов и полей/аргументов. Все идентификаторы должны начинаться с латинского символа и содержать только латинские символы, цифры и нижнее подчеркивание. Описание грамматики (BNF).
Пример:
namespace example;
interface MyInterface {
method(
arg0 int32,
arg1 string
) list<string>;
}
message MyMessage {
field0 int32;
field1 string;
}
enum Number {
ONE, TWO;
}
Комментарии
Есть два типа комментариев: однострочные и многострочные для документации. Комментарии документации могут быть помещены в самом начале модуля, перед определением типа (сообщением, интерфейсом или перечислением) или перед методом. Однострочные комментарии вырезаются при синтаксическом разборе, многострочные сохраняются и используются кодогенераторами для документации.
/**
* This is a multi-line module docstring.
* It is a available to the code generators.
*
* Start each line with a star because it is used
* as line whitespace/text delimiter when
* the docstring is indented (as method docstrings).
*/
namespace example;
// This is a one line comment, it is stripped from the source code.
/** Interface docstring. */
interface ExampleInterface {
/**
* Method docstring.
*/
hello(name string) string;
}
Пакеты и модули
Пакеты
Файлы пидефа должны быть организованы в пакеты. Каждый пакет описывается одним yaml
файлом, в котором содержится название пакета и перечисляются модули и завистимости. Циклические зависимости между пакетами запрещены. Имена модулей автомачески сопоставляются с файлами. Для этого точки заменяются на системный разделитель директорий и добавляется расширение .pdef
. Например, users.events
соответствует файлу users/events.pdef
. Зависимости указывают имя пакета и опциональный путь до его yaml
файла через пробел. Пути зависимостей можно задавать и переопределять при выполнении консольных команд.
Пример файла пакета:
package:
# Package name
name: example
# Additional information
version: 1.1
url: https://github.com/pdef/pdef/
author: Ivan Korobkov <[email protected]>
description: Example application
# Module files.
modules:
- example
- photos
- users
- users.profile
# Other packages this package depends on.
dependencies:
- common
- pdef_test https://raw.github.com/pdef/pdef/1.1/test/test.yaml
И его файловая структура (директория api
не обязательна):
api/
example.yaml
example.pdef
photos.pdef
users.pdef
users/profile.pdef
Модули и пространства имен
Модуль — это отдельный *.pdef
файл с описанием сообщений, интерфейсов и перечислений. Каждый модуль сразу после опциональной документации должен содержать указание на пространство имен. Все типы в одном пространстве имен должны иметь уникальные имена. Разные пакеты могут использовать одни и те же пространства имен.
Пространства имен в пидефе шире, чем в Java/С#/С++, и не должны соответствовать структуре файлов и директорий. Для последнего существуют названия модулей. Обычно, один или несколько пакетов используют одно пространство имен. Возможные примеры: twitter
, github
и т.д.
/** Module with a namespace. */
namespace myproject;
message Hello {
text string;
}
Импорты
Импорты похожи на include
в других языках, они позволяют в одном модуле обращаться к типам из другого модуля. Импорты помещаются сразу после указания пространства имен модуля. Модули импортируются с использованием имени пакета и пути файла без расширения .pdef
, с точкой вместо разделителя директорий. Когда имя модуля совпадает с именем пакета, модуль может быть импортирован только по имени пакета.
Отдельные импорты:
namespace example;
import package; // Equivalent to "import package.package" when package/module names match.
import package.module;
Пакетные импорты:
namespace example;
from package.module import submodule0, submodule1;
Циклические импорты и зависимости
Циклические импорты возможны до тех пор, пока типы одного модуля не наследуют типы другого модуля и наоборот. В противном случае можно попробовать разделить модули на более мелкие или объединить их в один файл. Циклические зависимости между типами разрешены.
Подобных ограничей достаточно для поддержки большинства языков программирования. Интерпретируемы языки, подобные Руби или Питону, тоже поддерживаются, так как компилятор Пидефа следит, что при наследовании модули будут иметь четкий древовидный порядок исполнения, в иных случаях модули могут быть выполнены в любом порядке. Подробнее о реализации циклических зависимостей в конкретных языках можно прочитать в Pdef Generated and Language-Specific Code
Пример циклических импортов и зависимостей:
// users.pdef
namespace example;
from example import photos; // Circular import.
message User {
bestFriend User; // References a declaring type.
photo Photo; // References a type from another module.
}
// photos.pdef
namespace example;
from example import users; // Circular import.
message Photo {
user User; // References a user from another module.
}
Разрешение имен
В рамках одного пространства имен используется локальное имя типа, например, MyMessage
, в рамках разных — полное имяnamespace.MyMessagе
.
Система типов
У Пидефа простая статическая система типов, построенная на принципе разделения интерфейсов и структур данных.
Void
void
это специальный тип, который указывает, что метод не возвращает результат.
Типы данных
Примитивные типы
bool
: булево значение (true/false)int16
: знаковое 16-битное числоint32
: знаковое 32-битное числоint64
: знаковое 64-битное числоfloat
: 32-битное число с плавающей точкойdouble
: 64-битное число с плавающей точкойstring
: строка Юникодdatetime
: дата в время без указания часового пояса
Контейнеры
list
: упорядоченный список, элементы которого могут быть любыми типами данных.set
: неупорядоченное множество уникальных значений, элементы которого могут быть любыми типами данных.map
: контейнер ключ-значение, ключи должны быть примитивными типами, значения могут быть любыми типами данных.
message Containers {
numbers list<int32>;
tweets list<Tweet>;
ids set<int64>;
colors set<Color>;
userNames map<int64, string>;
photos map<string, list<Photo>>;
}
Перечисления
Перечисление — это коллекция уникальный строковых значений. Также перечисления используются для указания дискриминаторов при наследовании.
enum Sex {
MALE, FEMALE;
}
enum EventType {
USER_REGISTERED,
USER_BANNED,
PHOTO_UPLOADED,
PHOTO_DELETED,
MESSAGE_RECEIVED;
}
Сообщения и исключения
Сообщение (аналог struct
‘а) — это коллекция статически типизированных именованных полей. Сообщения поддерживают простое и полиморфное наследование. Сообщения, определенные как исключения (exception
), дополнительно могут использоваться для указания исключений в интерефейсах.
- Все поля сообщения должны иметь уникальные имена.
- Тип поля должен быть типом данных.
- Поле может указывать на сообщение, в котором оно определено (self-referencing).
/** Example message. */
message User {
id int64;
name string;
age int32;
profile Profile;
friends set<User>; // Self-referencing.
}
/** Example exception. */
exception UserNotFound {
userId int64;
}
Наследование
Наследование позволяет одному сообщению наследовать поля другого сообщения или исключения. В простом наследовании потомки не могут быть распакованы из родителя, для этого есть полиморфное наследование.
- Циклическое наследование запрещено.
- У сообщения может быть только один родитель.
- Переопределение полей в потомках запрещено.
- Потомок и родитель должны быть либо сообщениями, либо исключениями, т.е. их нельзя смешивать.
- Родитель должен быть определен до его потомков, а также не может быть импортирован из зависимых модулей (подробнее в циклических импортах).
Пример наследования:
message EditableUser {
name string;
sex Sex;
birthday datetime;
}
message User : EditableUser {
id int32;
lastSeen datetime;
friendsCount int32;
likesCount int32;
photosCount int32;
}
message UserWithDetails : User {
photos list<Photo>;
friends list<User>;
}
Полиморфное наследование
Полиморфное наследование позволяет распаковывать потомков на основании значения поля дискриминатора. Родитель со всеми потомками является деревом наследования. Один потомок может наследовать другого (а не только родителя), но только в рамках одного дерева.
Для полиморфного наследования нужно:
- Создать перечисление, которое будет служить набором значений для дискриминатора.
- Добавить поле с типом этого перечисления в родительское сообщение и пометить его как
@discriminator
. - Указать значение дискриминатора каждого из потомков как
message Subtype : Base(DiscriminatorEnum.VALUE)
.
Ограничения:
- Родитель и все потомки должны быть определены в одном пакете.
- Тип дискриминатора должен быть определен до родителя и не может быть импортирован из зависимого модуля.
- В одном дереве наследования может быть только одно поле-дискриминатор.
- Нельзя наследовать полиморфное сообщение без указания значения дискриминатора.
Пример полиморфного наследования:
/** Discriminator enum. */
enum EventType {
USER_EVENT,
USER_REGISTERED,
USER_BANNED,
PHOTO_UPLOADED,
}
/** Base event with a discriminator field. */
message Event {
type EventType @discriminator; // The type field marked as @discriminator
time datetime;
}
/** Base user event. */
message UserEvent : Event(EventType.USER_EVENT) {
user User;
}
message UserRegistered : UserEvent(EventType.USER_REGISTERED) {
ip string;
browser string;
device string;
}
message UserBanned : UserEvent(EventType.USER_BANNED) {
moderatorId int64;
reason string;
}
message PhotoUploaded : Event(EventType.PHOTO_UPLOADED) {
photo Photo;
userId int64;
}
Интерфейсы
Интерфейс — это коллекция статически типизированных методов. Каждый метод имеет уникальное имя, именованные аргументы и результат. Результат может быть любым типом данных, включая другие интерфейсы.
Метод называется терминальным, когда он возвращает тип данных или void
. Метод называется интерфейсным, когда он возвращает интерфейс. Последовательный вызов методов должен заканчиваться терминальным методом, например,app.users().register("John Doe")
.
Терминальные методы могут быть помечены как @post
, чтобы отделять методы, изменяющие данные. Их аргументы могут быть также помечены как @post
. HTTP RPC отправляет эти методы как POST запросы, а @post
аргументы добавляет в тело запроса.
Терминальные методы, не помеченные @post
, могут иметь @query
аргументы, которые отправляются как HTTP query string.
- Методы интерфейса должны иметь уникальный имена.
- Аргументы должны иметь уникальные имена.
- Аргументы должны быть типами данных.
- Только терминальные методы могут быть помечены как
@post
. - Только терминальные методы, не помеченные как
@post
, могут иметь@query
аргументы. - Последний метод в цепочке вызовов должен быть терминальным.
Пример интерфейсов:
interface Application {
/** Void method. */
void0() void;
/** Interface method. */
service(arg int32) Service;
/** Method with 3 args. */
method(arg0 int32, arg1 string, arg2 list<string>) string;
}
interface Service {
/** Terminal method with @query args. */
query(limit int32 @query, offset int32 @query) list<string>;
/** Terminal post method with one of args marked as @post. */
@post
mutator(arg0 int32, postArg string @post) string;
}
Наследование интерфейсов
Интерфейсы могу наследовать другие интерфейсы.
- Переопределение методов запрещено.
- У наследника может быть только один родитель.
- Если у родителя определено исключение, то наследники должны либо не указывать исключения, либо указывать исключение родителя.
Пример наследования интерфейсов:
interface BaseInterface {
method() void;
}
interface SubInterface : BaseInterface {
anotherMethod() void;
}
Исключения
Исключения указываются в корневых интерфейсах с помощью @throws(Exception)
. Корневой интерфейс — это интерфейс, с которого начинаются все вызовы. Исключения других интерфейсов в цепочке вызовов игнорируются. Для поддержки множественных исключений используется полиморфное наследование или композиция. Обычно есть один корневой интерфейс, например, Github
или Twitter
, и одно исключение.
Пример полиморфных исключений:
@throws(AppException)
interface Application {
users() Users;
photos() Photos;
search() Search;
}
enum AppExceptionCode {
AUTH_EXC,
VALIDATION_EXC,
FORBIDDEN_EXC
}
exception AppException {
type AppExceptionCode @discriminator;
}
exception AuthExc : AppException(AppExceptionCode.AUTH_EXC) {}
exception ValidationExc : AppException(AppExceptionCode.VALIDATION_EXC) {}
exception ForbiddenExc : AppException(AppExceptionCode.FORBIDDEN_EXC) {}
Заключение
Написать черновой вариант компилятора было довольно просто, думаю, он был готов где-то через месяц работы в свободное время. Весь остальной год был потрачен на то, чтобы сделать пидеф относительно простым, недвусмысленным и удобным в использовании. В стабильную версию языка не попали дженерики, полиморфное наследование со множеством дискриминаторов, переопределение исключений в цепочках вызовов, открытая система типов (которая позволяла использовать собственные нативные типы, вроде native mytype
), слабая типизация (когда поле или результат метода имел тип object
, а клиенты должны были сами его распаковывать), а также многое другое. В результате, я надеюсь, получился простой, легкочитаемый и удобный в использовании язык.
Почему нет полноценной поддержки REST’а? Изначально, она планировалась, но спецификация и фунциональность и так получалась довольно объемной, поэтому REST был заменен на более простую реализацию HTTP RPC. В будущих версиях, возможно, он появится. Подробнее про RPC можно прочитать в спецификации, а примеры посмотреть на станицах байндингов конкретных языков. Ссылки есть в конце статьи.
Хотелось бы поделиться своими ощущениями от использования языка с точки зрения пользователя, а не автора. За последний год я использовал его в нескольких проектах, в некоторых из них даже альфа-версии. Мне пидеф нравится. Он повышает слабую связность компонентов, унифицирует типы, интерфейсы и документацию, и освобождает программистов от рутинного дублирования кода на разных языках.
Думаю, как уже писал в начале статьи, он сильно уменьшает накладные расходы на организацию взаимодействия различных систем, включая мобильные клиенты, сайты, апи-серверы, внутренние сервисы, распределенные системы, серверы пуш-нотификаций, очереди, системы хранения данных. Все они, в итоге, могут использовать одни и те же доступные типы данных и интерфейсы. При этом нет ни какого технологического lock-in’а, потому что внутри по-умолчанию это тот же JSON и HTTP.
Ссылки
- Сайт проекта pdef.io (перенаправляет на Гитхаб)
- Пример пакета
- Language guide
- Style guide
- JSON format specs
- HTTP RPC specs
- Grammar in BNF
- Generated and language specific code с примерами
- How to write a code generator
- Google group ([email protected])
- Java, Python, Objective-C кодогенераторы и байндинги (доступны в Central Maven, PyPI and CocoaPods соответственно)