Pdef — язык описания интерфейсов для веба

В начале прошлого года мне пришла в голову идея написать собственный язык интерфейсов (IDL), который был бы похож на Protobuf или Thrift, но предназначался бы для веба. Я надеялся закончить его где-нибудь месяца за три. До первой стабильной версии прошло чуть больше года.

Pdef (пидеф, protocol definition language) — это статически типизированный язык описания интерфейсов, который поддерживает JSON и HTTP RPC. Он позволяет один раз описать интерфейсы и структуры данных, а потом сгенерировать код для конкретных языков программирования. Пидеф подходит для публичных апи, внутренних сервисов, распределенных систем, конфигурационных файлов, как формат для хранения данных, кеша и очередей сообщений.

Основная функциональность:

  • Развитая система пакетов, модулей и пространств имен.
  • Поддержка циклических импортов и зависимостей типов (с некоторыми ограничениями).
  • Простая система типов, основанная на четком разделении интерфейсов и структур данных.
  • Наследование сообщений (аналог struct’ов) и интерфейсов.
  • Поддержка цепочек вызовов, например, github.user(1).repos().all().
  • JSON как формат данных и HTTP RPC для передачи данных.
  • Возможность использовать другие форматы и RPC.
  • Подключаемые кодогенераторы (официально поддерживаются JavaPython и 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/С#/С++, и не должны соответствовать структуре файлов и директорий. Для последнего существуют названия модулей. Обычно, один или несколько пакетов используют одно пространство имен. Возможные примеры: twittergithub и т.д.

/** 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.

Ссылки