Enhancing Localization(Internationalization) from Database with a Custom Annotation in Spring Boot
Localization and internationalization are concepts related to adapting software applications to different languages, regions, and cultural preferences to make them accessible and usable by people around the world.
Internationalization (often abbreviated as “i18n”) is the process of designing and developing software applications in a way that allows for easy adaptation to different languages, regions, and cultures without requiring code changes. It involves separating the user interface (UI) text, messages, and other localizable elements from the application’s code and storing them in resource files or databases. This enables the application to be easily translated and localized for different target audiences.
Localization (often abbreviated as “l10n”) is the process of adapting a software application that has been internationalized to a specific language, region, or culture. It involves translating the text, messages, labels, and other UI elements into the target language, as well as adjusting the application’s behavior and appearance to suit the cultural norms and preferences of the target audience. This may include formatting dates, numbers, currencies, and other locale-specific information correctly, as well as considering factors such as language direction (left-to-right or right-to-left), character encoding, and localized content.
Localization typically involves working with translators and linguists who are proficient in the target language and have an understanding of the target culture. They translate the text and adapt the application’s content to ensure it is linguistically and culturally appropriate for the target audience.
To implement localization with a custom annotation in Spring Boot, we can follow these step-by-step instructions:
Step 1: Create a Custom Annotation
Create a custom annotation to mark the fields that require localization. For example, let’s create a @I18NProperty
annotation:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface I18NProperty {
/**
* id to find the translation
*/
String fieldId() default "id";
/**
* type of translate
*/
String type();
String locale() default "";
}
Step 2: Create a Interface
A provider interface specifies a set of methods that must be implemented by a service provider in order to provide a specific functionality. It defines the contract or API that the provider must adhere to, without specifying the actual implementation details.now we greate a Interface with some parameters to query from database to get message from the specific locale. For example, let’s create a I18NProvider Interface:
public interface I18NProvider {
String getMessage(String key, String type, String locale, String defaultMessage);
}
Step 3: Create a extends class of StdSerializer
To serialize to JSON, we need to create a custom serializer using StdSerializer
, and extend the class and implement the serialize()
method. This method defines how Java object should be serialized to JSON. we can customize the serialization process according to our specific requirements.
Here’s an example of how you can create a custom serializer by extending StdSerializer
:
@Slf4j
public class I18NSerializer extends StdSerializer<Object> {
private static final long serialVersionUID = -2391442805192997903L;
private final I18NProvider provider;
public I18NSerializer(I18NProvider provider) {
super(Object.class);
this.provider = provider;
}
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider provider) throws IOException {
String i18nValue = getI18NProperty(value, gen);
if (ObjectUtils.isNotEmpty(i18nValue))
gen.writeString(i18nValue);
else
gen.writeNull();
}
@Override
public void serializeWithType(Object value, JsonGenerator gen, SerializerProvider serializers, TypeSerializer typeSer) throws IOException {
String i18nValue = getI18NProperty(value, gen);
if (ObjectUtils.isNotEmpty(i18nValue))
super.serializeWithType(i18nValue, gen, serializers, typeSer);
else
gen.writeNull();
}
private String getI18NProperty(Object value, JsonGenerator gen) {
try {
ObjectMapper objectMapper = (ObjectMapper) gen.getCodec();
PropertyNamingStrategy namingStrategy = objectMapper.getPropertyNamingStrategy();
String fieldName = gen.getOutputContext().getCurrentName();
if (PropertyNamingStrategies.SNAKE_CASE.equals(namingStrategy))
fieldName = this.snakeCaseToCamelCase(fieldName);
Object obj = gen.getCurrentValue();
I18NProperty annotation = obj.getClass().getDeclaredField(fieldName).getAnnotation(I18NProperty.class);
String locale = annotation.locale();
locale = StringUtils.isNotEmpty(locale) ? locale : LocaleContextHolder.getLocale().getLanguage();
String i18nId = (String) PropertyUtils.getProperty(obj, annotation.fieldId());
return this.provider.getMessage(i18nId, annotation.type(), locale.toLowerCase(), (String) value);
} catch (Exception e) {
log.warn("exception occurred while serializer I18N property {} of class {} {}", gen.getOutputContext().getCurrentName(), gen.getCurrentValue().getClass().getName(), e.getMessage());
return (String) value;
}
}
private String snakeCaseToCamelCase(String string) {
if (ObjectUtils.isEmpty(string)) return string;
StringBuilder sb = new StringBuilder(string);
for (int i = 0; i < sb.length(); i++) {
if (sb.charAt(i) == '_') {
sb.deleteCharAt(i);
sb.replace(i, i + 1, String.valueOf(Character.toUpperCase(sb.charAt(i))));
}
}
return sb.toString();
}
}
In this class, I18NSerialzer
extends StdSerializer<CustomObject>
, where I18nSerializer
is the implemented interface that we want got message to serialize. The serialize()
method is overridden to define the serialization logic of our message from the specific locale. In this case, it writes the fields of the annotated field@I18NProperty
to the JSON generator.
Step 4: Create a extends class of SimpleModuleSimpleModule
is a part of the Jackson ObjectMapper framework. Jackson is a popular library used for JSON (JavaScript Object Notation) serialization and deserialization in Java. To register custom serializers(I18NSerialzer
) and deserializers for specific types. It provides a simple way to extend the serialization and deserialization capabilities of the ObjectMapper.
public class I18NModule extends SimpleModule {
private static final long serialVersionUID = 8750960660810211977L;
private final I18NSerializer i18NSerializer;
public I18NModule(I18NProvider provider) {
Assert.notNull(provider, "I18N provider must not be null");
this.i18NSerializer = new I18NSerializer(provider);
}
@Override
public void setupModule(SetupContext context) {
context.addBeanSerializerModifier(new BeanSerializerModifier() {
@Override
public List<BeanPropertyWriter> changeProperties(SerializationConfig config, BeanDescription beanDesc, List<BeanPropertyWriter> beanProperties) {
for (BeanPropertyWriter writer : beanProperties) {
if (ObjectUtils.isNotEmpty(writer.getAnnotation(I18NProperty.class))) {
writer.assignSerializer(i18NSerializer);
writer.assignNullSerializer(i18NSerializer);
}
}
return beanProperties;
}
});
}
}
In this class, I18NModule
is a module to be registering a custom serializer (I18NSerialzer
) with the SimpleModule
.
Step 5: How we creare bean for this module(I18NModule
)
There are two ways to register this module as we can see from the below examples:
@Bean
public I18NModule i18NModule(I18nServiceImpl provider) {
return new I18NModule(provider);
}
// or
@Bean
public ObjectMapper mapper(I18nServiceImpl provider) {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
builder.failOnUnknownProperties(false);
ObjectMapper mapper = builder.build();
SerializerProvider serializerProvider = mapper.getSerializerProvider();
if (ObjectUtils.isNotEmpty(serializerProvider))
mapper.setSerializerProvider(new CustomDefaultSerializerProvider());
mapper.setPropertyNamingStrategy(new PropertyNamingStrategies.SnakeCaseStrategy());
mapper.registerModule(new I18NModule(provider));
return mapper;
}
The below class show how we set the default locale and supported locales and also how we create bean for this class
public class SmartLocaleResolver extends AcceptHeaderLocaleResolver {
@Override
public Locale resolveLocale(HttpServletRequest request) {
Locale defaultLocale = new Locale("en");
List<Locale> supportedLocales = Arrays.asList(new Locale("km"), new Locale("en"), new Locale("kr"));
if (StringUtils.isBlank(request.getHeader("Accept-Language"))) {
return defaultLocale;
}
this.setSupportedLocales(supportedLocales);
List<Locale.LanguageRange> list = Locale.LanguageRange.parse(request.getHeader("Accept-Language"));
Locale locale = Locale.lookup(list, getSupportedLocales());
if (ObjectUtils.isEmpty(locale))
return defaultLocale;
return locale;
}
}
@Bean
public LocaleResolver localeResolver() {
return new SmartLocaleResolver();
}
And all of these below classes, show how we store the message of locale in database and query back from the implementation of provider interface I18NProvider
@Setter
@Getter
@Entity
@Table(name = I18nEntity.TABLE_NAME)
public class I18nEntity {
public static final String TABLE_NAME = "i18n";
@Id
@Column(nullable = false, unique = true)
private String id;
@Column(nullable = false)
private String key;
@Column(nullable = false)
private String locale;
@Column(nullable = false)
private String type;
private String message;
@Column(name = "created_by")
protected String createdBy;
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "created_date")
protected Date createdDate;
@Override
public int hashCode() {
return Objects.hash(key, locale, type);
}
private String status;
}
create table public.i18n (
id bigserial not null,
"type" varchar(255) not null,
locale varchar(255) not null,
"key" varchar(255) not null,
created_by varchar(255) null,
created_date timestamp null,
message text null,
status varchar(255) null,
constraint i18n_pkey primary key (id)
);
@Data
public class I18nDTO implements Serializable {
private static final long serialVersionUID = 1905122041950251207L;
private String id;
private String key;
private String locale;
private String message;
private String type;
}
public interface I18nService {
}
@Service
@AllArgsConstructor
public class I18nServiceImpl implements I18nService, I18NProvider {
private final I18nRepository repository;
@Override
public String getMessage(String key, String type, String locale, String defaultMessage) {
I18nEntity entity = new I18nEntity();
entity.setKey(key);
entity.setType(type);
entity.setLocale(locale);
entity.setStatus("ACTIVE");
Optional<I18nEntity> nEntity = this.repository.findOne(Example.of(entity));
if (nEntity.isPresent())
return nEntity.get().getMessage();
return defaultMessage;
}
}
And finally here is the use case and sample
This sample will localize from header locale
@I18NProperty(fieldId = "gender", type = "gender.label")
private String gender;
private String id; // product id
@I18NProperty(type = "product.name")
private String productName;
This sample will localize for “km” only
private String id; // product id
@I18NProperty(type = "product.name", locale = "km")
private String productName;
@I18NProperty(fieldId = "gender", type = "gender.label", locale = "km")
private String gender;
To summarize; In this article, I tried to explain how to translate from any language from Database via Spring Data JPA or Hibernate and also you can use the localize from file too. I recommend to use this annotation from VO level before response to client only. You can access the source code via the github link above.
Thank you for reading until the end. Before you go:
- Please consider clapping and following the writer! 👏
- Visit phsophea101.github.io to find out more about how we are democratizing free programming education around the world.
- If you like my work and would like to buy me a coffee please go this buymeacoffee link. Thank you :)
Good luck!!!