Implementeren van DataManipulators

Dit zijn richtlijnen voor medewerkers die willen helpen bij Data API implementatie door het creëren van DataManipulators. Een bijgewerkte lijst van DataManipulators die moeten worden uitgevoerd vindt u bij ‘SpongeCommon kwestie #8 < https://github.com/SpongePowered/SpongeCommon/issues/8 >’ _.

Om een DataManipulator volledig te implementeren moet u deze stappen volgen:

  1. Implementeer de DataManipulator zelf

  2. Implementeer de ImmutableDataManipulator

Wanneer deze stappen zijn voltooid, moet het volgende ook gedaan worden:

  1. Registreer de Key in de KeyRegistry

  2. Implementeer de DataProcessor

  3. Implementatie van de ‘’ ValueProcessor’’ voor elke waarde die vertegenwoordigd wordt door de ‘’ DataManipulator’’

  4. Registreer alles in de SpongeSerializationRegistry

Notitie

Zorg ervoor dat u onze Richtlijnen voor het bijdragen volgt.

1. Implementeer de DataManipulator

De naamgeving voor `` DataManipulator`` implementaties is de naam van de interface voorafgegaan door “Sponge”. Dus om de``HealthData``-interface te implementeren, maken we een klasse aan met de naam`` SpongeHealthData`` in het juiste pakket. Om de`` DataManipulator`` eerst te implementeren moet het een passende abstracte klasse uit het`` org.spongepowered.common.data.manipulator.mutable.common``-pakket uitbreiden. De meest generieke is`` AbstractData`` maar er zijn ook abstracties die gewone code nog meer simplificieren voor sommige speciale gevallen zoals``DataManipulator``s die enkel één enkele waarde bevatten.

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

Er zijn twee type argumenten aan de klasse AbstractData. Het eerste is de interface die door deze klasse geïmplementeerd wordt, het tweede is de interface die is uitgevoerd door corresponderende ‘’ ImmutableDataManipulator’’.

De constructor

In most cases while implementing an abstract Manipulator you want to have two constructors:

  • Een zonder argumenten (no-args) roept de tweede constructor met de “standaard” waarden

  • De tweede constructor die alle waarden die het ondersteunt neemt.

De tweede constructor moet

  • bellen naar de ‘’ AbstractData’’-constructor, die verwijzen naar de klasse referentie voor de geïmplementeerde interface.

  • zorg ervoor dat de doorgegeven waarden geldig zijn

  • roep de methode ‘’ registerGettersAndSetters()’’ op

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();
    }

    ...

}

Aangezien we weten dat zowel de gezondheid van de huidige en maximale gezondheid gebonden waarden zijn, moeten wij ervoor zorgen dat geen waarden buiten deze grenzen kunnen worden doorgegeven. Om dit te bereiken gebruiken we de guava’s Preconditions waarvan wij de vereiste methoden statisch importeren.

Notitie

Gebruik nooit zogenaamde magische values (willekeurige getallen, booleans enz) in uw code. In plaats daarvan zoek de ‘’ org.spongepowered.common.data.util.DataConstants’’-klasse en gebruik een constante waarde - of maak er een indien nodig.

Accessors gedefinieerd door de Interface

De interface die we implementeren geeft u enkele methoden om waarmee u toegang krijgt tot Value objecten. Voor HealthData zijn dat health() en maxHealth(). Elke methode aanroep zou een nieuwe Value moeten opleveren.

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

Tip

Aangezien Double een Comparable is, hoeven we niet expliciet een comparator te specificeren.

Wanneer geen huidige waarde is gedefinieerd zal het aanroepen van get() op een Value resulteren in de teruggave van de standaardwaarde.

Kopiëren en serialisatie

De twee methoden copy() en asImmutable() zijn eenvoudig te implementeren. Voor beide moet men respectievelijk een veranderbare of onveranderbare data manipulator oproepen met dezelfde data als de huidige instantie.

De methode ‘’ toContainer()’’ is gebruikt voor serialisatiedoeleinden. Gebruik een ‘’ MemoryDataContainer’’ als het resultaat en breng dat dan aan de waarden die zijn opgeslagen deze instantie. Een ‘’ DataContainer’’ is in feite een kaart die ‘’ DataQuery’’ s omzet tot waarden. Aangezien een ‘’ toets ‘’ altijd een bijbehorende ‘’ DataQuery’’bevat moet je gewoon die passeren door de ‘’ toets ‘’ direct te gebruiken.

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

registerGettersAndSetters()

Een ‘’ DataManipulator’’ biedt ook methoden voor het ophalen en instellen van gegevens met behulp van de toetsen. De uitvoering voor dit wordt afgehandeld door de ‘’ AbstractData’’, maar we moeten het aangeven welke gegevens het kan benaderen en hoe. Daarom moeten we in de methode ‘’ registerGettersAndSetters()’’ het volgende doen voor elke waarde:

  • registreer een Supplier om dadelijk de waarde te krijgen

  • registreer een Consumer om dadelijk de waarde te krijgen

  • registreer een Supplier<Value> om een veranderlijke Value te krijgen

Supplier and Consumer zijn functionele interfaces, dus men kan Java 8 Lambdas gebruiken.

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);
}

De Consumer die geregistreerd is als field setter moet de genoodzaakte controle uitvoeren om zekere te zijn dat de geleverde waarde acceptabel is. Dit geldt speciaal voor DataHolder``s omdat die geen negatieve waarden accepteren. Als een waarde onacceptabel is dan moet ``IllegalArgumentException gemaakt worden.

Tip

The validity criteria for those setters are the same as for the respective Value object, so you might delegate the validity check to a call of this.health().set() and just set this.currentHealth = value if the first line has no thrown an exception yet.

That’s it. The DataManipulator should be done now.

2. Implementeren van de ImmutableDataManipulator

Implementing the ImmutableDataManipulator is similar to implementing the mutable one.

De enige verschillen zijn:

  • The class name is formed by prefixing the mutable DataManipulators name with ImmutableSponge

  • Inherit from ImmutableAbstractData instead

  • In plaats van registerGettersAndSetters(), heet de methode registerGetters()

When creating ImmutableDataHolders or ImmutableValues, check if it makes sense to use the ImmutableDataCachingUtil. For example if you have WetData which contains nothing more than a boolean, it is more feasible to retain only two cached instances of ImmutableWetData - one for each possible value. For manipulators and values with many possible values (like SignData) however, caching is proven to be too expensive.

Tip

You should declare the fields of an ImmutableDataManipulator as final in order to prevent accidental changes.

3. Registreer de sleutel in de KeyRegistry

The next step is to register your Keys to the KeyRegistry. To do so, locate the org.spongepowered.common.data.key.KeyRegistry class and find the static generateKeyMap() function. There add a line to register (and create) your used keys.

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

The keyMap maps strings to Keys. The string used should be the corresponding constant name from the Keys utility class in lowercase. The Key itself is created by one of the static methods provided by KeyFactory, in most cases makeSingleKey. makeSingleKey requires first a class reference for the underlying data, which in our case is a “Double”, then a class reference for the Value type used. The third argument is the DataQuery used for serialization. It is created from the statically imported DataQuery.of() method accepting a string. This string should also be the constant name, stripped of underscores and capitalization changed to upper camel case.

4. Implementeer de 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.

Tip

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.

Validation Methods

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.

Instead, we are required to provide a doesDataExist() method. Since the abstraction does not know how to obtain the data, it leaves this function to be implemented. As the name says, the method should check if the data already exists on the supported target. For the HealthDataProcessor, this always returns true, since every living entity always has health.

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;
}

Tip

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

Waarschuwing

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.

Removal is not abstracted in any abstract DataProcessor as the abstractions have no way of knowing if the data is always present on a compatible DataHolder (like WetData or HealthData) or if it may or may not be present (like LoreData). If the data is always present, remove() must always fail. If it may or may not be present, remove() should remove it. In such cases the doesDataExist() method should be overridden.

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

Getter methods obtain data from a DataHolder and return an optional DataManipulator. The DataProcessor interface specifies the methods from() and createFrom(), the difference being that from() will return Optional.empty() if the data holder is compatible, but currently does not contain the data, while createFrom() will provide a DataManipulator holding default values in that case.

Again, AbstractEntityDataProcessor will provide most of the implementation for this and only requires a method to get the actual values present on the DataHolder. This method is only called after supports() and doesDataExist() both returned true, which means it is run under the assumption that the data is present.

Waarschuwing

If the data may not always exist on the target DataHolder, e.g. if the remove() function may be successful (see above), it is imperative that you override the doesDataExist() method so that it returns true if the data is present and false if it is not.

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

A filler method is different from a getter method in that it receives a DataManipulator to fill with values. These values either come from a DataHolder or have to be deserialized from a DataContainer. The method returns Optional.empty() if the DataHolder is incompatible.

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.

Andere methoden

Depending on the abstract superclass used, some other methods may be required. For instance, AbstractEntityDataProcessor needs to create DataManipulator instances in various points. It can’t do this since it knows neither the implementation class nor the constructor to use. Therefore it utilizes an abstract function that has to be provided by the final implementation. This does nothing more than create a DataManipulator with default data.

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

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

5. Implementeer de 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);
    }

    [...]
}

Now the AbstractSpongeValueProcessor will relieve us of the necessity to check if the value is supported. It is assumed to be supported if the target ValueContainer is of the type EntityLivingBase.

Tip

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

Again, most work is done by the abstraction class. We just need to implement two helper methods for creating a Value and its immutable counterpart and three methods to get, set and remove data.

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;
}

The set() method will return a boolean value indicating whether the value could successfully be set. This implementation will reject values outside of the bounds used in our value construction methods above.

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. Registreer Processors

In order for Sponge to be able to use our manipulators and processors, we need to register them. This is done in the org.spongepowered.common.data.SpongeSerializationRegistry class. In the setupSerialization method there are two large blocks of registrations to which we add our processors.

DataProcessors

A DataProcessor is registered alongside the interface and implementation classes of the DataManipulator it handles. For every pair of mutable / immutable DataManipulators at least one DataProcessor must be registered.

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

ValueProcessors

Value processors are registered at the bottom of the very same function. For each Key multiple processors can be registered by subsequent calls of the registerValueProcessor() method.

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

Verdere informatie

With Data being a rather abstract concept in Sponge, it is hard to give general directions on how to acquire the needed data from the Minecraft classes itself. It may be helpful to take a look at already implemented processors similar to the one you are working on to get a better understanding of how it should work.

Als u vastloopt of niet zeker bent van bepaalde dingen, kunt u naar het #spongedev IRC kanaal gaan, de forums bezoeken of een issue op GitHub aanmaken. Zorg ervoor dat u de Data Processor Implementation Checklist checkt voor algemene bijdrage vereisten.