6
\$\begingroup\$

For our Cardshifter Project we wanted to be able to load card definitions from file.

A card is defined as:

  • Having an unique id.
  • Having a set of resources, which are integers identified by a string.
  • Having a set of attributes, which are strings identified by a string.

In the implementation every card gets mapped to an Entity in our Entity-Component Framework, however that is to be considered an implementation detail and is not the intended focus of this review.

This code is also accompanied with tests, but as there are lots of tests, they require Entity-Component Framework knowledge and are all accompanied with their own XML file, they are also not included.

You can review all code at our Github project page on the develop branch and the tests can be found here.

Then now follows the implementation code:

CardLoadingException class

public class CardLoadingException extends Exception {
    private static final long serialVersionUID = 64626564562564598L;

    public CardLoadingException() {

    }

    public CardLoadingException(final String message) {
        super(message);
    }

    public CardLoadingException(final Throwable cause) {
        super(cause);
    }

    public CardLoadingException(final String message, final Throwable cause) {
        super(message, cause);
    }
}

UncheckedCardLoadingException class

class UncheckedCardLoadingException extends RuntimeException {
    private static final long serialVersionUID = 6644614691255149498L;

    UncheckedCardLoadingException() {

    }

    UncheckedCardLoadingException(final String message) {
        super(message);
    }

    UncheckedCardLoadingException(final Throwable cause) {
        super(cause);
    }

    UncheckedCardLoadingException(final String message, final Throwable cause) {
        super(message, cause);
    }
}

CardLoader interface

/**
 * An interface to be implemented by cardloaders.
 *
 * @author Frank van Heeswijk
 * @param <I>   The input file type of the card loader on each individual load action
 */
public interface CardLoader<I> {
    /**
     * Loads cards from the given input resource.
     * 
     * This is done by mapping the input resources to the given resources.
     * 
     * @param input The input resource
     * @param entitySupplier    A supplier function that supplies a new entity
     * @param resources The resources to be transformed to, may b enull to indicate no resources present
     * @param attributes    The attributes to be transformed to, may be null to indicate no attributes present
     * @return  A collection of Entity instances
     * @throws CardLoadingException     If an error occured while loading the cards or a resource could not be mapped
     */
    Collection<Entity> loadCards(I input, Supplier<Entity> entitySupplier, ECSResource[] resources, ECSAttribute[] attributes) throws CardLoadingException;
}

CardLoaderHelper class

final class CardLoaderHelper {
    private CardLoaderHelper() {
        throw new UnsupportedOperationException();
    }

    /**
     * Sanitizes the input of a tag, which can be a tag for example a resource or an attribute.
     * 
     * The tag will be converted to lower case, trimmed and underscores will be removed.
     *
     * @param tag   The tag to be sanitized
     * @return  The sanitized tag
     */
    static String sanitizeTag(final String tag) {
        return tag.toLowerCase(Locale.ENGLISH).trim().replace("_", "");
    }

    /**
     * Returns the tags that are required when loading cards.
     * 
     * @return  The required tags
     */
    static List<String> requiredTags() {
        return Arrays.asList("id");
    }
}

XmlCardLoader class

public class XmlCardLoader implements CardLoader<Path> {
    @Override
    public Collection<Entity> loadCards(final Path path, final Supplier<Entity> entitySupplier, final ECSResource[] resources, final ECSAttribute[] attributes) throws CardLoadingException {
        Objects.requireNonNull(path, "path");
        Objects.requireNonNull(entitySupplier, "entitySupplier");

        List<ECSResource> resourcesList = (resources == null) ? Arrays.asList() : Arrays.asList(resources);
        List<ECSAttribute> attributesList = (attributes == null) ? Arrays.asList() : Arrays.asList(attributes);

        try {
            SAXBuilder saxBuilder = new SAXBuilder();
            Document document = saxBuilder.build(path.toFile());

            XMLOutputter xmlOutputter = new XMLOutputter(Format.getCompactFormat().setExpandEmptyElements(true));
            String unformattedXmlString = xmlOutputter.outputString(document);

            JacksonXmlModule xmlModule = new JacksonXmlModule();
            xmlModule.setDefaultUseWrapper(false);
            ObjectMapper xmlMapper = new XmlMapper(xmlModule);

            CardInfo cardInfo = xmlMapper.readValue(unformattedXmlString, CardInfo.class);

            List<String> tags = Stream.concat(resourcesList.stream(), attributesList.stream())
                .map(ecsElement -> sanitizeTag(ecsElement.toString()))
                .collect(Collectors.toList());

            if (requiredTags().stream().anyMatch(tags::contains)) {
                throw new UncheckedCardLoadingException("Tags " + requiredTags() + " are required by default you cannot submit them in the resources or attributes.");
            }

            List<String> duplicateTags = tags.stream()
                .collect(Collectors.groupingBy(i -> i))
                .entrySet()
                .stream()
                .filter(entry -> entry.getValue().size() > 1)
                .map(Entry::getKey)
                .collect(Collectors.toList());

            if (!duplicateTags.isEmpty()) {
                throw new UncheckedCardLoadingException("Tags " + duplicateTags + " have been input multiple times, this is not allowed.");
            }

            Map<String, ECSResource> ecsResourcesMap = resourcesList.stream()
                .collect(Collectors.toMap(ecsResource -> sanitizeTag(ecsResource.toString()), i -> i));

            Map<String, ECSAttribute> ecsAttributesMap = attributesList.stream()
                .collect(Collectors.toMap(ecsAttribute -> sanitizeTag(ecsAttribute.toString()), i -> i));

            List<Card> cardList = cardInfo.getCards().getCards();
            List<String> duplicateIds = cardList.stream()
                .collect(Collectors.groupingBy(Card::getId))
                .entrySet()
                .stream()
                .filter(entry -> entry.getValue().size() > 1)
                .map(Entry::getKey)
                .collect(Collectors.toList());

            if (!duplicateIds.isEmpty()) {
                throw new UncheckedCardLoadingException("Card ids " + duplicateIds + " are duplicaties, this is not allowed.");
            }

            return cardList.stream()
                .map(card -> {
                    Entity entity = entitySupplier.get();

                    entity.addComponent(new IdComponent(card.getId()));

                    ECSResourceMap resourceMap = ECSResourceMap.createFor(entity);
                    ECSAttributeMap attributeMap = ECSAttributeMap.createFor(entity);

                    card.getElements().forEach((sanitizedTag, value) -> {
                        if (ecsResourcesMap.containsKey(sanitizedTag)) {
                            resourceMap.set(ecsResourcesMap.get(sanitizedTag), Integer.parseInt(value.toString()));
                        }
                        else if (ecsAttributesMap.containsKey(sanitizedTag)) {
                            attributeMap.set(ecsAttributesMap.get(sanitizedTag), value.toString());
                        }
                        else {
                            throw new UncheckedCardLoadingException("Element " + sanitizedTag + " has not been found in the supplied resource and attribute mappings where card id = " + card.getId());
                        }
                    });

                    return entity;
                })
                .collect(Collectors.toList());
        } catch (UncheckedCardLoadingException ex) {
            throw new CardLoadingException(ex.getMessage(), ex.getCause());
        } catch (Exception ex) {
            throw new CardLoadingException(ex);
        }
    }

    private static class CardInfo {
        @JacksonXmlProperty(localName = "Cards")
        private Cards cards;

        public Cards getCards() {
            if (cards == null) {
                cards = new Cards();    //fix for Cards instance being null on empty cards list
            }
            return cards;
        }
    }

    private static class Cards {
        @JacksonXmlProperty(localName = "Card")
        private Card[] cards;

        public List<Card> getCards() {
            if (cards == null) {
                cards = new Card[0];
            }
            return Arrays.asList(cards);
        }
    }

    private static class Card {
        private String id;
        private boolean duplicateId = false;

        private final Map<String, Object> elements = new HashMap<>();

        private boolean duplicateElements = false;
        private final List<String> duplicateElementTags = new ArrayList<>();

        @JsonAnySetter
        private void addElement(final String tag, final Object value) {
            String sanitizedTag = sanitizeTag(tag);
            switch (sanitizedTag) {
                case "id":
                    if (id != null) {
                        duplicateId = true;
                        return;
                    }
                    id = value.toString();
                    break;
                default:
                    if (elements.containsKey(sanitizedTag)) {
                        duplicateElements = true;
                        duplicateElementTags.add(sanitizedTag);
                        return;
                    }
                    elements.put(sanitizedTag, value);
                    break;
            }
        }

        public String getId() {
            if (duplicateId) {
                throw new UncheckedCardLoadingException("Element id has duplicate entries");
            }
            if (id == null) {
                throw new UncheckedCardLoadingException("Required element id has not been set");
            }
            return id;
        }

        @JsonAnyGetter
        public Map<String, Object> getElements() {
            if (duplicateElements) {
                throw new UncheckedCardLoadingException("Elements " + duplicateElementTags + " have duplicate entries where card id = " + id);
            }
            return new HashMap<>(elements);
        }
    }
}

It is probably also useful to include these interfaces:

ECSResource interface

/**
 * Interface for Resource types
 */
public interface ECSResource {
    /**
     * Returns the value of this resource associated with the given entity.
     * 
     * @param entity    The entity for which the resource value is to be retrieved
     * @return  The resource value
     */
    default int getFor(final Entity entity) {
        return ResourceRetriever.forResource(this).getFor(entity);
    }
}

ECSAttribute interface

/**
 * Interface for Attribute types
 */
public interface ECSAttribute {
    /**
     * Returns the value of this attribute associated with the given entity.
     * 
     * @param entity    The entity for which the attribute value is to be retrieved
     * @return  The attribute value
     */
    default String getFor(final Entity entity) {
        return AttributeRetriever.forAttribute(this).getFor(entity);
    }
}

The most important external libraries used for dealing with the XML data are JDOM2 for the preprocessing and Jackson for the deserialization.

I can give you the names of the tests to give a hint about for which things I test:

  • testLoadNoCards
  • testLoadNoCardsNullResourcesAndAttributes
  • testLoadOneCard
  • testLoadOneCardWithoutUsingECSResourceOrECSAttribute
  • testLoadOneCardNoResourcesOrAttributes
  • testLoadOneCardWithSpecialCharacters
  • testLoadTwoCards
  • testLoadFourCardsSanitizedResources
  • testLoadFourCardsSanitizedAttributes
  • testLoadTwoCardsVerifyIntegerAttribute
  • testLoadFourCardsSanitizedResourcesIncorrectHealthResourcesMapping
  • testLoadFourCardsSanitizedAttributesIncorrectCreatureTypeAttributesMapping
  • testLoadOneCardWithDoubleResource
  • testLoadOneCardWithDoubleAttribute
  • testLoadOneCardWithDoubleResourceAndAttribute
  • testLoadOneCardResourceNotFound
  • testLoadOneCardAttributeNotFound
  • testLoadOneCardWithDoubleSanitizedResources
  • testLoadOneCardWithDoubleSanitizedAttributes
  • testLoadTwoCardsWithDuplicateIds
  • testLoadNoCardsWithIdResource
  • testLoadNoCardsWithIdAttribute

An example implementation can be observed in the following snippet of the test code to load in a file with two cards:

<?xml version="1.0" encoding="UTF-8"?>
<CardInfo>
    <Cards>
        <Card>
            <Id>1</Id>
            <Name>Test 1</Name>
            <Image>test1.jpg</Image>
            <CardType>testcard</CardType>
            <TR1>5</TR1>
            <TR2>-6</TR2>
        </Card>
        <Card>
            <Id>2</Id>
            <Name>Test 2</Name>
            <Image>test2.jpg</Image>
            <CardType>testcard2</CardType>
            <TR1>3</TR1>
            <TR2>-8</TR2>
        </Card>
    </Cards>
</CardInfo>

Relevant declarations:

private static enum TestResources implements ECSResource {
    TR1, TR2;
}

private static enum TestAttributes implements ECSAttribute {
    NAME, IMAGE, CARDTYPE; 
}

Relevant test:

@Test
public void testLoadTwoCards() throws URISyntaxException, CardLoadingException {
    Path xmlFile = Paths.get(getClass().getResource("two-cards.xml").toURI());

    ECSGame game = new ECSGame();

    XmlCardLoader xmlCardLoader = new XmlCardLoader();
    Collection<Entity> entities = xmlCardLoader.loadCards(xmlFile, game::newEntity, TestResources.values(), TestAttributes.values());

    Entity card = findEntityWithId(entities, "1");
    assertEquals("1", card.getComponent(IdComponent.class).getId());
    assertEquals("Test 1", TestAttributes.NAME.getFor(card));
    assertEquals("test1.jpg", TestAttributes.IMAGE.getFor(card));
    assertEquals("testcard", TestAttributes.CARDTYPE.getFor(card));
    assertEquals(5, TestResources.TR1.getFor(card));
    assertEquals(-6, TestResources.TR2.getFor(card));

    Entity card2 = findEntityWithId(entities, "2");
    assertEquals("2", card2.getComponent(IdComponent.class).getId());
    assertEquals("Test 2", TestAttributes.NAME.getFor(card2));
    assertEquals("test2.jpg", TestAttributes.IMAGE.getFor(card2));
    assertEquals("testcard2", TestAttributes.CARDTYPE.getFor(card2));
    assertEquals(3, TestResources.TR1.getFor(card2));
    assertEquals(-8, TestResources.TR2.getFor(card2));
}

I'd like to get a review on all aspects. The XmlCardLoader class for example has been refactored a few times to incorporate new constraints and I am intending that the refactorings do not leave their marks on the source code.

\$\endgroup\$
4
\$\begingroup\$

Readability

For me this is clearly to many lines of code inside this method. So let us see, what you are doing.

  1. you are creating a CardInfo object by using the Path path.
  2. you are creating a List<String>'s and validate them
  3. you are createing Map<String, T's and use them to prodcude the result

As it seems 1. and 2. won't change in the near future but add a lot of codelines to this method. So let us extract them to separate methods.

private CardList readFromFile(File file)
{
    SAXBuilder saxBuilder = new SAXBuilder();
    Document document = saxBuilder.build(file);

    XMLOutputter xmlOutputter = new XMLOutputter(Format.getCompactFormat().setExpandEmptyElements(true));
    String unformattedXmlString = xmlOutputter.outputString(document);

    JacksonXmlModule xmlModule = new JacksonXmlModule();
    xmlModule.setDefaultUseWrapper(false);
    ObjectMapper xmlMapper = new XmlMapper(xmlModule);

    return xmlMapper.readValue(unformattedXmlString, CardInfo.class);
}

private List<String> getValidatedTags(List<ECSResource> resourcesList, List<ECSAttribute> attributesList)
{
    List<String> tags = Stream.concat(resourcesList.stream(), attributesList.stream())
        .map(ecsElement -> sanitizeTag(ecsElement.toString()))
        .collect(Collectors.toList());

    validateTags(tags);

    return tags;
}  

private void validateTags(List<String> tags)
{

    if (requiredTags().stream().anyMatch(tags::contains)) {
        throw new UncheckedCardLoadingException("Tags " + requiredTags() + " are required by default you cannot submit them in the resources or attributes.");
    }

    List<String> duplicateTags = tags.stream()
        .collect(Collectors.groupingBy(i -> i))
        .entrySet()
        .stream()
        .filter(entry -> entry.getValue().size() > 1)
        .map(Entry::getKey)
        .collect(Collectors.toList());

    if (!duplicateTags.isEmpty()) {
        throw new UncheckedCardLoadingException("Tags " + duplicateTags + " have been input multiple times, this is not allowed.");
    }

}

private List<Card> getValidatedCards(CardInfo cardInfo)
{
    List<Card> cards = cardInfo.getCards().getCards();

    List<String> duplicateIds = cards.stream()
        .collect(Collectors.groupingBy(Card::getId))
        .entrySet()
        .stream()
        .filter(entry -> entry.getValue().size() > 1)
        .map(Entry::getKey)
        .collect(Collectors.toList());

    if (!duplicateIds.isEmpty()) {
        throw new UncheckedCardLoadingException("Card ids " + duplicateIds + " are duplicaties, this is not allowed.");
    }

    return cards;
}

Now, after renaming cardList to cards and rearanging the creating of the Map<String, T>'s the former loadCards() method looks like

@Override
public Collection<Entity> loadCards(final Path path, final Supplier<Entity> entitySupplier, final ECSResource[] resources, final ECSAttribute[] attributes) throws CardLoadingException {
    Objects.requireNonNull(path, "path");
    Objects.requireNonNull(entitySupplier, "entitySupplier");

    List<ECSResource> resourcesList = (resources == null) ? Arrays.asList() : Arrays.asList(resources);
    List<ECSAttribute> attributesList = (attributes == null) ? Arrays.asList() : Arrays.asList(attributes);

    try {

        CardInfo cardInfo = readFromFile(path.toFile());

        List<String> tags = getValidatedTags(resourcesList, attributesList);

        List<Card> cards = getValidatedCards(cardInfo);

        Map<String, ECSResource> ecsResourcesMap = resourcesList.stream()
            .collect(Collectors.toMap(ecsResource -> sanitizeTag(ecsResource.toString()), i -> i));

        Map<String, ECSAttribute> ecsAttributesMap = attributesList.stream()
            .collect(Collectors.toMap(ecsAttribute -> sanitizeTag(ecsAttribute.toString()), i -> i));

        return cards.stream()
            .map(card -> {
                Entity entity = entitySupplier.get();

                entity.addComponent(new IdComponent(card.getId()));

                ECSResourceMap resourceMap = ECSResourceMap.createFor(entity);
                ECSAttributeMap attributeMap = ECSAttributeMap.createFor(entity);

                card.getElements().forEach((sanitizedTag, value) -> {
                    if (ecsResourcesMap.containsKey(sanitizedTag)) {
                        resourceMap.set(ecsResourcesMap.get(sanitizedTag), Integer.parseInt(value.toString()));
                    }
                    else if (ecsAttributesMap.containsKey(sanitizedTag)) {
                        attributeMap.set(ecsAttributesMap.get(sanitizedTag), value.toString());
                    }
                    else {
                        throw new UncheckedCardLoadingException("Element " + sanitizedTag + " has not been found in the supplied resource and attribute mappings where card id = " + card.getId());
                    }
                });

                return entity;
            })
            .collect(Collectors.toList());
    } catch (UncheckedCardLoadingException ex) {
        throw new CardLoadingException(ex.getMessage(), ex.getCause());
    } catch (Exception ex) {
        throw new CardLoadingException(ex);
    }
}

What you are needed to do is add the trows part to the extracted methods, as I don't have Java 8 or any of these used libraries installed.

CardInfo,Cards and Card classes

What I don't like here is that CardInfo holds a field Cards cards and that Cards holds a field Card[] cards. I don't see any need for the Cards class here.

Comments

I am confused by the xml comment of the interface CardLoader<I>. It says

@param <I>   The input file type of the card loader on each individual load action  

and the XmlCardLoader`` implementsCardLoader. In my opinionPath!=input file type`. So it would better state

@param <I>   The input source of the card loader on each individual load action

and the signatur in the interface should be changed to

Collection<Entity> loadCards(I source, ...)  

and the xml comment for the method itself also.

\$\endgroup\$
  • \$\begingroup\$ Agreed on the number of lines in the method, but I wanted an alternative opinion (than my own) on that. The Cards class is I believe how Jackson/XML works (best), it is ugly. Good review overall. \$\endgroup\$ – skiwi Nov 7 '14 at 9:38
1
\$\begingroup\$

I have to say that this code is good.

Comment:

  • Streams are lazy, and laziness is amazing performance wise, try to use it as much as you can, and that is, don't call collect unless you need to do so.

    List<String> tags = Stream.concat(resourcesList.stream(), attributesList.stream())
            .map(ecsElement -> sanitizeTag(ecsElement.toString()))
            .collect(Collectors.toList());
    

    Can be replaced by a Stream

    Stream<String> tagsStream = Stream.concat(resourcesList.stream(), attributesList.stream())
            .map(ecsElement -> sanitizeTag(ecsElement.toString()));
    

    And now, you can use this tagsStream to do your filters and any cool stuff without creating a stream from the list every time you want to do some streaming.

\$\endgroup\$
  • 1
    \$\begingroup\$ The resulting tagsStream can then be used only once, I used a list there as there is a possibility that I wanted to use it multiple times, I maybe even used it multiple times there. \$\endgroup\$ – skiwi Nov 6 '14 at 19:13
  • \$\begingroup\$ @skiwi but why don't use the stream instead? you doing a lot of tag.stream() right? \$\endgroup\$ – Sleiman Jneidi Nov 6 '14 at 20:08
  • 1
    \$\begingroup\$ A stream cannot be operated on after a final operation has been done on that stream. Inside a method you always never want to use streams as interim results, it can however sometimes be smart to return a stream from a method, because then you have a good indication that you are supposed to use that stream only once. \$\endgroup\$ – skiwi Nov 6 '14 at 20:30

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.