Thursday, July 05, 2012

Round Half Up using Annotation-Driven Formatting from Spring 3.1

Even though bankers prefer to round half even (the default in Java Decimal Format) in Accounting (surprisingly?) rounding half up is preferred at least in some scenarios. Besides the monetary implications which should concern only business there is a fundamental problem on the technology side and it is: Where should the rounding be done?

My attention was brought to a JSTL fmt:formatNumber which is still missing the apparently necessary rounding method. This made me think twice if rounding actually belongs to front end at all.

I believe in separation of concerns as one of the most important concepts a good Architect must master and in this case I can't see why the rounding strategy and even how many decimal places should be a front end concern. It looks wrong to have to format numbers in a native Ipad app plus a native Android app plus a WEB/WAP/HTML5 app. That cannot be the way. Let us don't even talk about testing and maintainability.

There is a process called binding which is applied one way or the other when a Controller passes or accepts content to a form in the View. I clearly can see how the number conversions (if any) should be done as part of Binding.

Below is how you achieve this from a Spring Controller using the InitBinder annotation and a Formatter:
 @InitBinder
    public void initBinder(WebDataBinder dataBinder) {
        dataBinder.registerCustomEditor(Date.class, new MultipleDateEditor());
        DecimalFormat df = new DecimalFormat();
        df.setMaximumFractionDigits(3); //Round always to the third digit
        df.setRoundingMode(RoundingMode.HALF_UP); //Default is HSLF_EVEN
        dataBinder.registerCustomEditor(BigDecimal.class, "mtdQtdNetReturn", new CustomNumberEditor(BigDecimal.class, df, true));
        ...
    }
The above is valid only for forms if you want to persist the rounded numbers. But formatting is needed in non form pages as well and in fact you probably need to keep your forms presenting and persisting data with all decimal places while listing and detail pages need certain rounding (BTW persisting rounded values is probably a bad practice but that is a subject for a different discussion).

One way or the other for informational pages there should be a solution of course. It makes sense to have a Formatter applied to all relevant fields.

Spring 3.1 allows to use annotations like @NumberFormat. If you are using JPA make sure the Spring annotation is the closest to the field otherwise it won't work. Here is a the right approach:
@Column(precision = 8, scale = 6)
@NumberFormat(pattern = "###,###.###")
private BigDecimal mtdQtdNetReturn;
@NumberFormat is missing though several important features of a number formatter when it comes to Decimal/BigDecimal format. One of those is rounding. Another handy one is multiplier and while pattern allows to specify the fraction digits it does not work for Currency or Percent types. I had to hook into Spring Conversion API to get rounding/multiplier/fractionDigits supported and here is how I did it with a CustomNumberFormatter (and some other necessary Spring classes).

Let us consider the number 454.2225. Here are some examples of how the number can be formatted (US-EN locale) and the corresponding options for the annotation:
  1. 454.223
    @CustomNumberFormat(style = Style.NUMBER, fractionDigits = 3, roundingMode = RoundingMode.HALF_UP, multiplier = 100)
    
  2. 454.223%
    @CustomNumberFormat(style = Style.PERCENT, fractionDigits = 3, roundingMode = RoundingMode.HALF_UP, multiplier = 100)
    
  3. $454.223
    @CustomNumberFormat(style = Style.CURRENCY, fractionDigits = 3, roundingMode = RoundingMode.HALF_UP, multiplier = 100)
    
In spring configuration file:
<mvc:annotation-driven conversion-service="conversionService"/>
    
    <bean id="conversionService"
          class="org.springframework.format.support.FormattingConversionServiceFactoryBean">
        <property name="formatters">
            <set>
                <bean class="com.nestorurquiza.format.number.CustomNumberFormatAnnotationFormatterFactory"/>
            </set>
        </property>
    </bean>
In JSP:
<spring:eval expression="clientAssetValue.mtdQtdNetReturn"/>
Here is the Spring Factory:
package com.nestorurquiza.format.number;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

import org.springframework.context.EmbeddedValueResolverAware;
import org.springframework.format.AnnotationFormatterFactory;
import org.springframework.format.Formatter;
import org.springframework.format.Parser;
import org.springframework.format.Printer;
import org.springframework.format.number.CustomCurrencyFormatter;
import org.springframework.util.StringUtils;
import org.springframework.util.StringValueResolver;

import com.nestorurquiza.format.annotation.CustomNumberFormat;
import com.nestorurquiza.format.annotation.CustomNumberFormat.Style;

public class CustomNumberFormatAnnotationFormatterFactory implements AnnotationFormatterFactory<CustomNumberFormat>, EmbeddedValueResolverAware {

    private final Set<Class<?>> fieldTypes;
    
    private StringValueResolver embeddedValueResolver;
    
    public CustomNumberFormatAnnotationFormatterFactory() {
        Set<Class<?>> rawFieldTypes = new HashSet<Class<?>>(1);
        rawFieldTypes.add(BigDecimal.class);
        this.fieldTypes = Collections.unmodifiableSet(rawFieldTypes);
    }
    
    @Override
    public void setEmbeddedValueResolver(StringValueResolver resolver) {
        this.embeddedValueResolver = resolver;
    }

    @Override
    public final Set<Class<?>> getFieldTypes() {
        return this.fieldTypes;
    }

    @Override
    public Printer<?> getPrinter(CustomNumberFormat annotation, Class<?> fieldType) {
        return configureFormatterFrom(annotation);
    }

    @Override
    public Parser<?> getParser(CustomNumberFormat annotation, Class<?> fieldType) {
        return configureFormatterFrom(annotation);
    }
    
    protected String resolveEmbeddedValue(String value) {
        return (this.embeddedValueResolver != null ? this.embeddedValueResolver.resolveStringValue(value) : value);
    }
    
    /**
     * Style.NUMBER accepts pattern, multiplier and roundingMode
     * Style.CURRENCY accepts 
     * @param annotation
     * @return
     */
    private Formatter<Number> configureFormatterFrom(CustomNumberFormat annotation) {
        RoundingMode roundingMode = annotation.roundingMode();
        int multiplier = annotation.multiplier();
        int fractionDigits = annotation.fractionDigits();
        
        Style style = annotation.style();
        if (style == Style.PERCENT) {
            CustomPercentFormatter bigDecimalPercentFormatter = new CustomPercentFormatter();
            bigDecimalPercentFormatter.setRoundingMode(roundingMode);
            bigDecimalPercentFormatter.setFractionDigits(fractionDigits);
            bigDecimalPercentFormatter.setMultiplier(multiplier);
            return bigDecimalPercentFormatter;
        }
        else if (style == Style.CURRENCY) {
            CustomCurrencyFormatter currencyFormatter = new CustomCurrencyFormatter();
            currencyFormatter.setRoundingMode(roundingMode);
            currencyFormatter.setFractionDigits(fractionDigits);
            currencyFormatter.setMultiplier(multiplier);
            return currencyFormatter;
        }
        else {
            CustomNumberFormatter numberFormatter = new CustomNumberFormatter();
            if (StringUtils.hasLength(annotation.pattern())) {
                numberFormatter.setPattern(resolveEmbeddedValue(annotation.pattern()));
            }
            numberFormatter.setRoundingMode(roundingMode);
            numberFormatter.setFractionDigits(fractionDigits);
            numberFormatter.setMultiplier(multiplier);
            
            return numberFormatter;
        }
        

    }
    
    
}
As you can see the Factory refers to an annotation and three formatters. Here is the annotation:
package com.nestorurquiza.format.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.math.RoundingMode;

@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
public @interface CustomNumberFormat {
    /**
     * The style pattern to use to format the field.
     * Defaults to {@link Style#NUMBER} for general-purpose number formatter.
     * Set this attribute when you wish to format your field in accordance with a common style other than the default style.
     */
    Style style() default Style.NUMBER;

    /**
     * The custom pattern to use to format the field.
     * Defaults to empty String, indicating no custom pattern String has been specified.
     * Set this attribute when you wish to format your field in accordance with a custom number pattern not represented by a style.
     */
    String pattern() default "";
    
    int multiplier() default 1;
    
    RoundingMode roundingMode() default RoundingMode.HALF_EVEN; 

    int fractionDigits() default 2;

    /**
     * Common number format styles.
     * @author Keith Donald
     * @since 3.0
     */
    public enum Style {

        /**
         * The general-purpose number format for the current locale.
         */
        NUMBER,
        
        /**
         * The currency format for the current locale.
         */
        CURRENCY,

        /**
         * The percent format for the current locale.
         */
        PERCENT
    }
}
The formatters:
package com.nestorurquiza.format.number;

import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.Locale;

import org.springframework.format.number.NumberFormatter;

public class CustomNumberFormatter extends NumberFormatter {

    private int multiplier;
    private RoundingMode roundingMode; 
    private int fractionDigits = 2;
    
    public int getMultiplier() {
        return multiplier;
    }


    public void setMultiplier(int multiplier) {
        this.multiplier = multiplier;
    }


    public RoundingMode getRoundingMode() {
        return roundingMode;
    }


    public void setRoundingMode(RoundingMode roundingMode) {
        this.roundingMode = roundingMode;
    }

    

    public int getFractionDigits() {
        return fractionDigits;
    }


    public void setFractionDigits(int fractionDigits) {
        this.fractionDigits = fractionDigits;
    }


    @Override
    public NumberFormat getNumberFormat(Locale locale) {
        NumberFormat format = super.getNumberFormat(locale);
        DecimalFormat decimalFormat = (DecimalFormat) format;
        decimalFormat.setMultiplier(multiplier);
        decimalFormat.setRoundingMode(roundingMode);
        decimalFormat.setMaximumFractionDigits(fractionDigits);
        return decimalFormat;
    }

}  
package com.nestorurquiza.format.number;

import java.math.RoundingMode;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.Locale;

import org.springframework.format.number.AbstractNumberFormatter;

public class CustomPercentFormatter extends AbstractNumberFormatter {

    private RoundingMode roundingMode = RoundingMode.HALF_EVEN;
    private int multiplier = 1;
    private int fractionDigits = 2;
    
    public RoundingMode getRoundingMode() {
        return roundingMode;
    }


    public void setRoundingMode(RoundingMode roundingMode) {
        this.roundingMode = roundingMode;
    }


    public int getMultiplier() {
        return multiplier;
    }


    public void setMultiplier(int multiplier) {
        this.multiplier = multiplier;
    }

    

    public int getFractionDigits() {
        return fractionDigits;
    }


    public void setFractionDigits(int fractionDigits) {
        this.fractionDigits = fractionDigits;
    }


    protected NumberFormat getNumberFormat(Locale locale) {
        NumberFormat format = NumberFormat.getPercentInstance(locale);
        if (format instanceof DecimalFormat) {
            DecimalFormat decimalFormat = ((DecimalFormat) format);
            decimalFormat.setParseBigDecimal(true);
            decimalFormat.setMultiplier(multiplier);
            decimalFormat.setRoundingMode(roundingMode);
            decimalFormat.setMaximumFractionDigits(fractionDigits);
        }
        return format;
    }

}
package org.springframework.format.number;

import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.util.Locale;

public class CustomCurrencyFormatter extends CurrencyFormatter {

    private int multiplier = 1;
    
    @Override
    protected NumberFormat getNumberFormat(Locale locale) {
        DecimalFormat format = (DecimalFormat) super.getNumberFormat(locale);
        format.setMultiplier(multiplier);
        return format;
    }

    public int getMultiplier() {
        return multiplier;
    }

    public void setMultiplier(int multiplier) {
        this.multiplier = multiplier;
    }
    
    
}
Note that I am forced to use a Spring package for the CustomCurrencyFormatter to be able to extend the original class. Hopefully Spring will include these options to their @NumberFormat implementation in future versions.

2 comments:

Fabio said...

Thanx a lot Nestor for this useful post! Formatting with annotation it's very very interesting!

Fabio

Fabio said...
This comment has been removed by the author.

Followers