Реализация DataManipulators

Это руководство для тех, кто хочет помочь с реализацией Data API, создав DataManipulators. Обновленный список DataManipulators, который должен быть реализован, можно найти в SpongeCommon Issue #8.

Чтобы полностью реализовать DataManipulator, необходимо выполнить следующие шаги:

  1. Реализовать DataManipulator

  2. Реализовать ImmutableDataManipulator

По завершению этих шагов, необходимо также выполнить следующее:

  1. Зарегистрировать Key в KeyRegistry

  2. Реализовать DataProcessor

  3. Реализовать ValueProcessor для каждого значения, представленного DataManipulator

  4. Зарегистрировать всё в SpongeSerializationRegistry

If the data applies to a block, several methods must also be mixed in to the block.

Примечание

Убедитесь, что вы следуете нашим правилам.

1. Реализовать DataManipulator

Соглашение об именах для реализаций DataManipulator является именем интерфейса с префиксом «Sponge». Поэтому для реализации интерфейса HealthData мы создаем класс с именем SpongeHealthData в соответствующем пакете. Для реализации DataManipulator сначала нужно расширить соответствующий абстрактный класс из пакета org.spongepowered.common.data.manipulator.mutable.common. Самым общим является AbstractData, но есть абстракции, которые еще более сокращают шаблонный код для некоторых специальных случаев, например для DataManipulator-ов, содержащих только одно значение.

public class SpongeHealthData extends AbstractData<HealthData, ImmutableHealthData> implements HealthData {
    [...]
}

Класс AbstractData содержит два типовых аргумента. Первый — интерфейс, реализованный этим классом, второй — интерфейс, реализуемый соответствующим ImmutableDataManipulator.

Конструктор

In most cases while implementing an abstract DataManipulator you need to have two constructors:

  • Один без аргументов (no-args), который вызывает второй конструктор со значениями «по умолчанию»

  • Второй конструктор, который принимает все поддерживаемые значения.

Второй конструктор должен

  • сделать вызов конструктора AbstractData, передав ссылку на класс для реализованного интерфейса.

  • убедиться, что передаваемые значения действительны

  • вызвать метод registerGettersAndSetters()

import static com.google.common.base.Preconditions.checkArgument;

public class SpongeHealthData {

    public SpongeHealthData() {
        this(DataConstants.DEFAULT_HEALTH, DataConstants.DEFAULT_HEALTH);
    }

    public SpongeHealthData(double currentHealth, double maxHealth) {
        super(HealthData.class);
        checkArgument(currentHealth >= DataConstants.MINIMUM_HEALTH && currentHealth <= (double) Float.MAX_VALUE);
        checkArgument(maxHealth >= DataConstants.MINIMUM_HEALTH && maxHealth <= (double) Float.MAX_VALUE);
        this.currentHealth = currentHealth;
        this.maximumHealth = maxHealth;
        this.registerGettersAndSetters();
    }

    ...

}

Поскольку мы знаем, что значения current health и maximum health являются ограниченными, мы должны убедиться, что любые значения за пределами этих границ не передадутся. Для достижения этого мы используем guava Preconditions из которых мы статически импортируем необходимые методы.

Примечание

Никогда не используйте в своем коде так называемые магические значения (произвольные числа, логические значения и т.д.). Вместо этого найдите класс org.spongepowered.common.data.util.DataConstants и используйте подходящую константу. Или создайте её, если это необходимо.

Методы определенные интерфейсом

Интерфейс, который мы реализуем, определяет некоторые методы для доступа к объектам Value. Для HealthData это health() и maxHealth(). Каждый вызов к ним должен дать новое Value.

public MutableBoundedValue<Double> health() {
    return SpongeValueFactory.boundedBuilder(Keys.HEALTH)
        .minimum(DataConstants.MINIMUM_HEALTH)
        .maximum(this.maximumHealth)
        .defaultValue(this.maximumHealth)
        .actualValue(this.currentHealth)
        .build();
}

Совет

Поскольку Double является Comparable, нам не нужно явно указывать компаратор.

Если текущее значение не указано, вызов get() в Value возвращает значение по умолчанию.

Копирование и Сериализация

Два метода copy()` и asImmutable() не сложны для реализации. В обоих методах вам необходимо вернуть изменяемый либо не изменяемый «data manipulator» соответственно, содержащий те же значения что и текущий объект.

Метод toContainer() используется для целей сериализации. Используйте в качестве результата MemoryDataContainer и примените к нему значения, хранящиеся в этом экземпляре. DataContainer является в основном отображением карты DataQuery-ов в значения. Так как Key всегда содержит соответствующий DataQuery, просто используйте их, передавая непосредственно Key.

public DataContainer toContainer() {
    return new MemoryDataContainer()
        .set(Keys.HEALTH, this.currentHealth)
        .set(Keys.MAX_HEALTH, this.maximumHealth);
}

registerGettersAndSetters()

DataManipulator также предоставляет методы для получения и установки данных с использованием ключей. Реализация для этого обрабатывается AbstractData, но мы должны указать, какие данные он может получить и как. Поэтому в методе registerGettersAndSetters() нам нужно сделать следующее для каждого значения:

  • зарегистрировать Supplier для непосредственного получения значения

  • зарегистрировать ``Consumer``для непосредственной установки значения

  • зарегистрировать Supplier<Value>``для получения изменяемого ``Value

Supplier и Consumer являются функциональными интерфейсами, поэтому для них могут использоваться лямбда-выражения из Java 8.

private void setCurrentHealthIfValid(double value) {
    if (value >= DataConstants.MINIMUM_HEALTH && value <= (double) Float.MAX_VALUE) {
        this.currentHealth = value;
    } else {
        throw new IllegalArgumentException("Invalid value for current health");
    }
}

private void setMaximumHealthIfValid(double value) {
    if (value >= DataConstants.MINIMUM_HEALTH && value <= (double) Float.MAX_VALUE) {
        this.maximumHealth = value;
    } else {
        throw new IllegalArgumentException("Invalid value for maximum health");
    }

}

private void registerGettersAndSetters() {
    registerFieldGetter(Keys.HEALTH, () -> SpongeHealthData.this.currentHealth);
    registerFieldSetter(Keys.HEALTH, SpongeHealthData.this::setCurrentHealthIfValid);
    registerKeyValue(Keys.HEALTH, SpongeHealthData.this::health);

    registerFieldGetter(Keys.MAX_HEALTH, () -> SpongeHealthData.this.maximumHealth);
    registerFieldSetter(Keys.MAX_HEALTH, SpongeHealthData.this::setMaximumHealthIfValid);
    registerKeyValue(Keys.MAX_HEALTH, SpongeHealthData.this::maxHealth);
}

Consumer, зарегистрированный как установщик поля, должен выполнить соответствующие проверки, чтобы убедиться, что поставляемое значение допустимо. Это особенно касается DataHolder-ов, которые не принимают отрицательных значений. Если значение недопустимо, должно быть выброшено исключение IllegalArgumentException.

Совет

Критерии достоверности для этих установщиков такие же, как для соответствующего объекта Value, поэтому вы можете делегировать проверку действительности вызову this.health().set() и просто установить this.currentHealth = value, если первая строка ещё не выбрасывает исключение.

Вот и всё. DataManipulator должен быть выполнен.

2. Реализация ImmutableDataManipulator

Реализация ImmutableDataManipulator аналогична реализации mutable.

Единственные отличия:

  • Имя класса формируется путем добавления префикса ImmutableSponge к имени изменяемого DataManipulator

  • Наследование от ImmutableAbstractData

  • Метод registerGettersAndSetters() заменён на registerGetters()

При создании ImmutableDataHolder-ов или ImmutableValue-й, проверьте, имеет ли смысл использовать ImmutableDataCachingUtil. Например, если у вас есть WetData, который не содержит ничего, кроме логическое значение, более целесообразно будет сохранить только два кэшированных экземпляра ImmutableWetData — по одному для каждого возможного значения. Однако для манипуляторов и значений со многими возможными значениями (например, SignData) кэширование окажется слишком затратным.

Совет

Вы должны объявить поля ImmutableDataManipulator как final, чтобы предотвратить случайные изменения.

3. Регистрация Key в KeyRegistry

Следующим шагом является регистрация вашего Key-ев в `` KeyRegistry``. Чтобы сделать это, найдите класс org.spongepowered.common.data.key.KeyRegistry и найдите статическую функцию generateKeyMap(). Там добавьте строку для регистрации (и создания) используемых ключей.

keyMap.put("health"), makeSingleKey(Double.class, MutableBoundedValue.class, of("Health")));
keyMap.put("max_health", makeSingleKey(Double.class, MutableBoundedValue.class, of("MaxHealth")));

keyMap сопоставляет строки с Key-ми. Используемая строка должна быть соответствующим именем константы из служебного класса Keys в нижнем регистре. Ключ Key создается одним из статических методов, предоставляемых KeyFactory, в большинстве случаев makeSingleKey. makeSingleKey требует сначала ссылку на класс для базовых данных, которая в нашем случае является «Double», а затем ссылку на класс для типа используемого Value. Третий аргумент — это DataQuery, используемый для сериализации. Он создается из статически импортируемого метода DataQuery.of(), принимающего строку. Эта строка также должна быть именем константы, с линией подчеркивания и заглавной буквой, в стиле UpperCamelCase.

4. Реализация DataProcessors

Next up is the DataProcessor. A DataProcessor serves as a bridge between our DataManipulator and Minecraft’s objects. Whenever any data is requested from or offered to DataHolders that exist in Vanilla Minecraft, those calls end up being delegated to a DataProcessor or a ValueProcessor.

For your name, you should use the name of the DataManipulator interface and append Processor. Thus for HealthData we create a HealthDataProcessor.

In order to reduce boilerplate code, the DataProcessor should inherit from the appropriate abstract class in the org.spongepowered.common.data.processor.common package. Since health can only be present on certain entities, we can make use of the AbstractEntityDataProcessor which is specifically targeted at Entities based on net.minecraft.entity.Entity. AbstractEntitySingleDataProcessor would require less implementation work, but cannot be used as HealthData contains more than just one value.

public class HealthDataProcessor extends AbstractEntityDataProcessor<EntityLivingBase, HealthData, ImmutableHealthData> {
    public HealthDataProcessor() {
        super(EntityLivingBase.class);
    }
    [...]
}

Depending on which abstraction you use, the methods you have to implement may differ greatly, depending on how much implementation work already could be done in the abstract class. Generally, the methods can be categorized.

Совет

It is possible to create multiple DataProcessors for the same data. If vastly different DataHolders should be supported (for example both a TileEntity and a matching ItemStack), it may be beneficial to create one processor for each type of DataHolder in order to make full use of the provided abstractions. Make sure you follow the package structure for items, tileentities and entities.

Методы проверки

Always return a boolean value. If the method is called supports() it should perform a general check if the supplied target generally supports the kind of data handled by our DataProcessor.

For our HealthDataProcessor supports() is implemented by the AbstractEntityDataProcessor. Per default, it will return true if the supplied argument is an instance of the class specified when calling the super() constructor.

Вместо этого мы должны предоставить метод doesDataExist(). Поскольку абстракция не знает, как получить эти данные, она оставляет эту функцию для реализации. Как видно из его названия, метод должен проверить, существуют ли уже данные на поддерживаемой цели. Для HealthDataProcessor он всегда возвращается с результатом «истина», потому что каждое живое существо всегда обладает здоровьем.

protected boolean doesDataExist(EntityLivingBase entity) {
    return true;
}

Setter Methods

A setter method receives a DataHolder of some sort and some data that should be applied to it, if possible.

The DataProcessor interface defines a set() method accepting a DataHolder and a DataManipulator which returns a DataTransactionResult. Depending on the abstraction class used, some of the necessary functionality might already be implemented.

In this case, the AbstractEntityDataProcessor takes care of most of it and just requires a method to set some values to return true if it was successful and false if it was not. All checks if the DataHolder supports the Data is taken care of, the abstract class will just pass a Map mapping each Key from the DataManipulator to its value and then construct a DataTransactionResult depending on whether the operation was successful or not.

protected boolean set(EntityLivingBase entity, Map<Key<?>, Object> keyValues) {
    entity.getEntityAttribute(SharedMonsterAttributes.maxHealth)
        .setBaseValue(((Double) keyValues.get(Keys.MAX_HEALTH)).floatValue());
    entity.setHealth(((Double) keyValues.get(Keys.HEALTH)).floatValue());
    return true;
}

Совет

To understand DataTransactionResult s, check the corresponding docs page and refer to the DataTransactionResult.Builder docs to create one.

Предупреждение

Especially when working with ItemStacks it is likely that you will need to deal with NBTTagCompounds directly. Many NBT keys are already defined as constants in the org.spongepowered.common.data.util.NbtDataUtil class. If your required key is not there, you need to add it in order to avoid „magic values“ in the code.

Removal Method

The remove() method attempts to remove data from the DataHolder and returns a DataTransactionResult.

Удаление не абстрагируется ни в каком абстрактном DataProcessor, поскольку абстракции не имеют возможности узнать, всегда ли данные присутствуют на совместимом DataHolder (например, `` WetData`` или HealthData), а также могут или не могут они присутствовать (например, LoreData). Если данные всегда присутствуют, remove() должен всегда закончиться неуспехом. Если они могут или не могут присутствовать, remove() должен удалить их. В таких случаях метод doesDataExist() должен быть переопределен.

Since a living entity always has health, HealthData is always present and removal therefore not supported. Therefore we just return failNoData() and do not override the doesDataExist() method.

public DataTransactionResult remove(DataHolder dataHolder) {
    return DataTransactionBuilder.failNoData();
}

Getter Methods

Методы получения берут данные от DataHolder и возвращают опциональный DataManipulator. Интерфейс DataProcessor определяет методы from() и createFrom(), разница между которыми состоит в том, что from() вернет Optional.empty(), если держатель данных совместим, но в данный момент не содержит данных, a createFrom() предоставит DataManipulator, содержащий значения по умолчанию в данном случае.

Опять же, AbstractEntityDataProcessor будет обеспечивать большую часть реализации этого, и требует только метода, обеспечивающего присутствие фактических значений в DataHolder. Этот метод вызывается только после того, как supports() и doesDataExist() оба возвращаются с результатом «истина», а это означает, что он действует, предполагая присутствие данных.

Предупреждение

Если данные не всегда могут существовать в целевом DataHolder, например, при успешной функции remove () (см. выше), вам необходимо переопределить метод doesDataExist() так, чтобы он возвращался с результатом «истина» при наличии данных и «ложь» при их отсутствии.

protected Map<Key<?>, ?> getValues(EntityLivingBase entity) {
    final double health = entity.getHealth();
    final double maxHealth = entity.getMaxHealth();
    return ImmutableMap.<Key<?>, Object>of(Keys.HEALTH, health, Keys.MAX_HEALTH, maxHealth);
}

Filler Methods

Метод заполнения отличается от метода получения в том, что он принимает DataManipulator для заполнения значениями. Эти значения или поступают от DataHolder, или должны быть десериализованы из DataContainer. Этот метод вернется с результатом Optional.empty(), если DataHolder несовместим.

AbstractEntityDataProcessor already handles filling from DataHolders by creating a DataManipulator from the holder and then merging it with the supplied manipulator, but the DataContainer deserialization it can not provide.

public Optional<HealthData> fill(DataContainer container, HealthData healthData) {
    final Optional<Double> health = container.getDouble(Keys.HEALTH.getQuery());
    final Optional<Double> maxHealth = container.getDouble(Keys.MAX_HEALTH.getQuery());
    if (health.isPresent() && maxHealth.isPresent()) {
        healthData.set(Keys.HEALTH, health.get());
        healthData.set(Keys.MAX_HEALTH, maxHealth.get());
        return Optional.of(healthData);
    }
    return Optional.empty();
}

The fill() method is to return an Optional of the altered healthData, if and only if all required data could be obtained from the DataContainer.

Other Methods

В зависимости от используемого абстрактного суперкласса могут потребоваться некоторые другие методы. Например, процесcору «AbstractEntityDataProcessor» нужно создать экземпляры «DataManipulator» в разных точках. Он не может этого сделать, поскольку не знает ни класса реализации, ни используемого конструктора. Поэтому он задействует абстрактную функцию, которую должна обеспечить финальная реализация. Это не что иное, как создание «DataManipulator» с данными по умолчанию.

If you implemented your DataManipulator as recommended, you can just use the no-args constructor.

protected HealthData createManipulator() {
    return new SpongeHealthData();
}

5. Implement the ValueProcessors

Not only a DataManipulator may be offered to a DataHolder, but also a keyed Value on its own. Therefore, you need to provide at least one ValueProcessor for every Key present in your DataManipulator. A ValueProcessor is named after the constant name of its Key in the Keys class in a fashion similar to its DataQuery. The constant name is stripped of underscores, used in upper camel case and then suffixed with ValueProcessor.

A ValueProcessor should always inherit from AbstractSpongeValueProcessor, which already will handle a portion of the supports() checks based on the type of the DataHolder. For Keys.HEALTH, we’ll create and construct HealthValueProcessor as follows.

public class HealthValueProcessor extends AbstractSpongeValueProcessor<EntityLivingBase, Double,
    MutableBoundedValue<Double> {

    public HealthValueProcessor() {
        super(EntityLivingBase.class, Keys.HEALTH);
    }

    [...]
}

Теперь «AbstractSpongeValueProcessor» избавит нас от необходимости проверять, поддерживается ли это значение. Предполагается, что оно поддерживается, если целевой «ValueContainer» имеет тип «EntityLivingBase».

Совет

For a more fine-grained control over what EntityLivingBase objects are supported, the supports(EntityLivingBase) method can be overridden.

Опять же, большая часть работы выполняется классом абстракции. Нам просто нужно реализовать два вспомогательных метода для создания «Value» и его неизменяемого прототипа и три метода для получения, установки и удаления данных.

protected MutableBoundedValue<Double> constructValue(Double value) {
    return SpongeValueFactory.boundedBuilder(Keys.HEALTH)
        .minimum(DataConstants.MINIMUM_HEALTH)
        .maximum((double) Float.MAX_VALUE)
        .defaultValue(DataConstants.DEFAULT_HEALTH)
        .actualValue(value)
        .build();
}

protected ImmutableValue<Double> constructImmutableValue(Double value) {
    return constructValue(value).asImmutable();
}
protected Optional<Double> getVal(EntityLivingBase container) {
    return Optional.of((double) container.getHealth());
}

Since it is impossible for an EntityLivingBase to not have health, this method will never return Optional.empty().

protected boolean set(EntityLivingBase container, Double value) {
    if (value >= DataConstants.MINIMUM_HEALTH && value <= (double) Float.MAX_VALUE) {
        container.setHealth(value.floatValue());
        return true;
    }
    return false;
}

Метод set() возвращает логическое значение, которое указывает, может ли это значение быть успешно установлено. Эта реализация будет отклонять значения за пределами границ, используемых в наших методах построения значений, описанных выше.

public DataTransactionResult removeFrom(ValueContainer<?> container) {
    return DataTransactionBuilder.failNoData();
}

Since the data is guaranteed to be always present, attempts to remove it will just fail.

6. Register Processors

Чтобы Sponge мог использовать наши манипуляторы и процессоры, нам необходимо их зарегистрировать. Это осуществляется в классе org.spongepowered.common.data.SpongeSerializationRegistry. В методе setupSerialization есть два больших блока регистрации, к которым мы добавляем наши процессоры.

DataProcessors

DataProcessor зарегистрирован рядом с интерфейсом и классами реализации манипулятора DataManipulator, который он обрабатывает. Для каждой пары изменяемых / неизменяемых манипуляторов DataManipulator должен быть зарегистрирован хотя бы один DataProcessor.

dataRegistry.registerDataProcessorAndImpl(HealthData.class, SpongeHealthData.class,
    ImmutableHealthData.class, ImmutableSpongeHealthData.class,
    new HealthDataProcessor());

ValueProcessors

Процессоры значений зарегистрированы в нижней части той же самой функции. Для каждого «Key» могут быть зарегистрированы несколько процессоров путем последовательных вызовов метода «registerValueProcessor()».

dataRegistry.registerValueProcessor(Keys.HEALTH, new HealthValueProcessor());
dataRegistry.registerValueProcessor(Keys.MAX_HEALTH, new MaxHealthValueProcessor());

Реализация Block Data

Данные блока несколько отличаются от других типов данных, поскольку они реализованы путем смешивания с самим блоком. Существуют несколько методов в org.spongepowered.mixin.core.block.MixinBlock, которые должны быть переопределены для реализации данных для блоков.

@Mixin(BlockHorizontal.class)
public abstract class MixinBlockHorizontal extends MixinBlock {

    [...]
}

supports() should return true if either the ImmutableDataManipulator interface is assignable from the Class passed in as the argument, or the superclass supports it.

@Override
public boolean supports(Class<? extends ImmutableDataManipulator<?, ?>> immutable) {
    return super.supports(immutable) || ImmutableDirectionalData.class.isAssignableFrom(immutable);
}

getStateWithData() должен возвращать новый BlockState с данными из ``ImmutableDataManipulator`“, примененного к нему. Если манипулятор не поддерживается напрямую, метод должен делегироваться в суперкласс.

@Override
public Optional<BlockState> getStateWithData(IBlockState blockState, ImmutableDataManipulator<?, ?> manipulator) {
    if (manipulator instanceof ImmutableDirectionalData) {
        final Direction direction = ((ImmutableDirectionalData) manipulator).direction().get();
        final EnumFacing facing = DirectionResolver.getFor(direction);
        return Optional.of((BlockState) blockState.withProperty(BlockHorizontal.FACING, facing));
    }
    return super.getStateWithData(blockState, manipulator);
}

getStateWithValue() is the equivalent of getStateWithData(), but works with single Keys.

@Override
public <E> Optional<BlockState> getStateWithValue(IBlockState blockState, Key<? extends BaseValue<E>> key, E value) {
    if (key.equals(Keys.DIRECTION)) {
        final Direction direction = (Direction) value;
        final EnumFacing facing = DirectionResolver.getFor(direction);
        return Optional.of((BlockState) blockState.withProperty(BlockHorizontal.FACING, facing));
    }
    return super.getStateWithValue(blockState, key, value);
}

Finally, getManipulators() should return a list of all ImmutableDataManipulators the block supports, along with the current values for the provided IBlockState. It should include all ImmutableDataManipulators from the superclass.

@Override
public List<ImmutableDataManipulator<?, ?>> getManipulators(IBlockState blockState) {
    return ImmutableList.<ImmutableDataManipulator<?, ?>>builder()
            .addAll(super.getManipulators(blockState))
            .add(new ImmutableSpongeDirectionalData(DirectionResolver.getFor(blockState.getValue(BlockHorizontal.FACING))))
            .build();
}

Further Information

Поскольку Data является довольно абстрактным понятием в Sponge, трудно дать общие указания о том, как получить необходимые данные из самих классов Minecraft. Может быть полезно взглянуть на уже реализованные процессоры, аналогичные тому, над которым вы работаете, чтобы лучше понять, как он должен работать.

Если вы застряли или не уверены в некоторых аспектах, зайдите на канал интернет-ретрансляции «#spongedev», на форумы или откройте вопрос на GitHub. Обязательно проверьте ‘Контрольный список реализации процессора данных <https://github.com/SpongePowered/SpongeCommon/issues/8>_ на предмет соответствия общим требованиям к участию.