Spring Boot Jackson Deep Dive: Dynamic Field Masking, Date Formatting & Streaming JSON Optimization
This article explores advanced Jackson customization in Spring Boot, covering ObjectMapper configuration, global date formatting and BigDecimal precision handling, dynamic sensitive field desensitization using BeanSerializerModifier and ThreadLocal context, and streaming JSON generation with JsonGenerator to handle large datasets without memory overflow.
1. ObjectMapper Core Mechanism and Spring Boot Auto-Configuration
1.1 ObjectMapper's Role
ObjectMapperis Jackson's core entry point; all serialization and deserialization pass through it. Internally it maintains JsonSerializer and JsonDeserializer, annotation metadata, type resolution, and various providers. In short, it is the central manager for JSON operations.
The serialization flow roughly follows these steps:
Call objectMapper.writeValue(...) with the target object. ObjectMapper finds the corresponding JsonSerializer from SerializerFactory based on the object's runtime type.
The JsonSerializer writes each field of the object to a JsonGenerator (backed by an OutputStream or StringWriter).
Finally, the JSON string is output.
Understanding this flow becomes essential when customizing desensitization, as you will need to work with BeanSerializerModifier.
1.2 Spring Boot's Auto-Configuration
Spring Boot uses JacksonAutoConfiguration to automatically handle most setup:
If Jackson is on the classpath, it automatically creates an ObjectMapper bean.
It reads spring.jackson.* properties (date format, time zone, naming strategy, etc.) to initialize the bean.
It registers JavaTimeModule so that java.time types serialize/deserialize correctly.
It allows further customization via Jackson2ObjectMapperBuilderCustomizer.
In Web MVC, MappingJackson2HttpMessageConverter uses the same ObjectMapper.
Therefore, you generally should not create your own ObjectMapper bean; doing so disables the auto-configuration and can cause subtle bugs. Prefer Jackson2ObjectMapperBuilderCustomizer for customization.
1.3 Ways to Customize ObjectMapper
Method 1: Configuration File ( application.yml )
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
default-property-inclusion: non_nullThis approach is simple but date-format only applies to java.util.Date; it does not affect LocalDateTime. If your project uses LocalDateTime, you need the next method.
Method 2: Jackson2ObjectMapperBuilderCustomizer (Recommended)
This is the official recommended extension point, allowing you to add configuration on top of auto-configuration. Example: globally unify LocalDateTime format.
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> {
builder.serializerByType(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
builder.deserializerByType(LocalDateTime.class,
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
builder.serializationInclusion(JsonInclude.Include.NON_NULL);
};
}
}Method 3: Directly Define ObjectMapper Bean (Not Recommended)
Defining your own ObjectMapper bean discards Spring Boot's auto-configuration benefits (module registration, property binding, etc.). If absolutely necessary, use Jackson2ObjectMapperBuilder to build it, but proceed with caution.
2. Date Format, BigDecimal Precision, and Null Handling
2.1 LocalDateTime Formatting
The default LocalDateTime serialization produces 2024-06-01T10:30:00, which front-end teams often find inconvenient. The quickest fix is annotating each field with
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai"). However, annotating every date field is verbose; a global LocalDateTimeSerializer (via Method 2 above) is more maintainable.
2.2 BigDecimal Precision Issue
BigDecimalvalues like 0.00000001 may serialize to scientific notation ( 1E-8), which is unacceptable in financial systems. Two solutions:
Use @JsonSerialize(using = ToStringSerializer.class) to output as a string.
Use @JsonFormat(shape = JsonFormat.Shape.STRING).
Both achieve the same result; the second is preferred because it avoids writing a custom serializer class.
2.3 Null Value Handling
By default, null fields appear as "field": null, bloating responses and forcing front-end null checks. Globally configuring JsonInclude.Include.NON_NULL removes null fields entirely. If you need nulls to become empty strings instead, you must write a custom serializer — similar in concept to the desensitization approach covered later.
3. Custom Serializer for Dynamic Sensitive Field Desensitization
3.1 Requirements Analysis
Define desensitization types: mobile phone, ID card, bank card, etc.
Support dynamic strategy: decide whether to mask based on the current user's role (e.g., admin sees full data, regular user sees masked data).
Zero intrusion on business code; DTOs remain unchanged.
3.2 Custom Annotation and Serialization Design
Define a @SensitiveField annotation for fields requiring masking:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface SensitiveField {
SensitiveStrategy strategy() default SensitiveStrategy.MOBILE;
}And an enum for strategies:
public enum SensitiveStrategy {
MOBILE("手机号脱敏"),
ID_CARD("身份证号脱敏");
// ...
}The challenge: JsonSerializer is resolved by type and cannot access field-level annotations. The solution is to use BeanSerializerModifier during bean serializer construction to inspect each property; if a property carries @SensitiveField, replace its default serializer with a custom desensitizing serializer.
3.3 Intercepting Field Serializers with BeanSerializerModifier
Implement SensitiveFieldSerializer that wraps the default serializer and the desensitization strategy. During serialization, it checks a thread-local context to determine if the current user is an admin; if so, it delegates to the default serializer, otherwise it writes the masked string.
public class SensitiveFieldSerializer extends JsonSerializer<Object> {
private final JsonSerializer<Object> delegate;
private final SensitiveStrategy strategy;
public SensitiveFieldSerializer(JsonSerializer<Object> delegate, SensitiveStrategy strategy) {
this.delegate = delegate;
this.strategy = strategy;
}
@Override
public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value == null) {
gen.writeNull();
return;
}
if (SensitiveContext.isAdmin()) {
delegate.serialize(value, gen, serializers);
return;
}
String text = value.toString();
gen.writeString(mask(text, strategy));
}
private String mask(String value, SensitiveStrategy strategy) {
switch (strategy) {
case MOBILE:
return value.replaceAll("(\d{3})\d{4}(\d{4})", "$1****$2");
case ID_CARD:
return value.replaceAll("(\d{4})\d{10}(\w{4})", "$1**********$2");
default:
return value;
}
}
} SensitiveContextis a ThreadLocal holder for the current user's admin flag. It must be set in an interceptor or filter and cleared in afterCompletion to avoid thread-pool reuse issues.
Then implement BeanSerializerModifier:
public class SensitiveBeanSerializerModifier extends BeanSerializerModifier {
@Override
public JsonSerializer<?> changePropertySerializer(SerializationConfig config,
BeanDescription beanDesc, BeanPropertyDefinition property,
JsonSerializer<?> defaultSerializer) {
AnnotatedField field = property.getField();
if (field != null) {
SensitiveField annotation = field.getAnnotation(SensitiveField.class);
if (annotation != null) {
return new SensitiveFieldSerializer((JsonSerializer<Object>) defaultSerializer, annotation.strategy());
}
}
return super.changePropertySerializer(config, beanDesc, property, defaultSerializer);
}
}Register the modifier via Jackson2ObjectMapperBuilderCustomizer:
@Configuration
public class JacksonSensitiveConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer sensitiveCustomizer() {
return builder -> builder.postConfigurer(mapper -> {
mapper.setSerializerFactory(mapper.getSerializerFactory()
.withSerializerModifier(new SensitiveBeanSerializerModifier()));
return mapper; // important: return the mapper
});
}
}Note: the lambda in postConfigurer must return the ObjectMapper instance.
3.4 Dynamic Desensitization Context
SensitiveContextuses a ThreadLocal<Boolean> to store the admin flag:
public class SensitiveContext {
private static final ThreadLocal<Boolean> ADMIN = new ThreadLocal<>();
public static void setAdmin(boolean admin) {
ADMIN.set(admin);
}
public static boolean isAdmin() {
return Boolean.TRUE.equals(ADMIN.get());
}
public static void clear() {
ADMIN.remove();
}
}With this setup, the same endpoint returns full data for admins and masked data for regular users without any changes to business logic.
4. JsonGenerator Streaming Write for Large Data Volumes
4.1 Basic Usage
When exporting reports with tens of thousands of records, loading all data into a list before serialization causes OOM. Streaming with JsonGenerator keeps memory usage constant by writing each record as it is fetched from a database cursor.
ObjectMapper mapper = new ObjectMapper();
try (JsonGenerator generator = mapper.getFactory().createGenerator(outputStream, JsonEncoding.UTF8)) {
generator.writeStartArray();
while (rs.next()) {
generator.writeStartObject();
generator.writeStringField("id", rs.getString("id"));
generator.writeStringField("name", rs.getString("name"));
generator.writeNumberField("amount", rs.getBigDecimal("amount"));
generator.writeEndObject();
}
generator.writeEndArray();
}Here rs is a JDBC ResultSet; it can be replaced with MyBatis Cursor or any iterator. The key is: write one object, release one object, memory stays flat.
4.2 Combining with ObjectMapper Serializers
To avoid manually writing each field, let ObjectMapper 's serializers write directly to the JsonGenerator:
try (JsonGenerator generator = mapper.getFactory().createGenerator(outputStream)) {
generator.writeStartArray();
while (cursor.hasNext()) {
mapper.writeValue(generator, cursor.next());
}
generator.writeEndArray();
}This way mapper.writeValue invokes the normal serialization pipeline, so previously configured desensitization and date formatting all take effect. Streaming output therefore inherits all customizations automatically.
4.3 Important Notes
Always close JsonGenerator (use try-with-resources or finally); otherwise output may be incomplete.
When writing directly to a Spring MVC response, set Content-Type and CharacterEncoding, create the JsonGenerator from HttpServletResponse.getOutputStream(), and ensure Spring does not process the return value again (return null or use a void method; ResponseEntity is not suitable).
5. YAML vs JSON Performance Comparison
A colleague asked whether YAML could be used for API responses since it looks cleaner. In production, YAML parsing is significantly slower because it must handle indentation, anchors, and type conversions — its parser maintains many states. JSON's grammar is simple; its parser is essentially a state machine, and Jackson has heavily optimized it. JSON is typically an order of magnitude faster. Use JSON for data exchange; YAML is fine for configuration files parsed once at startup.
6. Field Naming Strategy Integration Tips
Java backends typically use camelCase ( userName) while some front-end teams prefer snake_case ( user_name). Instead of manual alignment, configure a global naming strategy in Jackson:
spring:
jackson:
property-naming-strategy: SNAKE_CASEThis automatically maps userName to user_name. For fields that must retain camelCase, use @JsonProperty("emailAddress") to explicitly specify the name; it has the highest priority. Example:
public class UserDTO {
@JsonProperty("emailAddress")
private String emailAddress;
}This combination (global strategy + local override) is common, convenient, and flexible. Custom naming strategies are rarely needed; only implement PropertyNamingStrategies.NamingBase and override translate for highly unusual requirements, then register it via builder.propertyNamingStrategy(new CustomNamingStrategy()) in a Jackson2ObjectMapperBuilderCustomizer.
7. Summary
Jackson customization boils down to understanding ObjectMapper and remembering a few key extension points: configuration, custom serializers, BeanSerializerModifier, and streaming JsonGenerator. Date formatting and BigDecimal precision are routine tasks solved with annotations or global config. Desensitization and streaming are slightly more involved but the principles are straightforward. Naming strategy should follow team conventions; don't over-engineer for perfection. Write code that is good enough.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
