Cara mengimplementasikan objek domain Spring Boot/entitas JPA yang menangani panggilan balik acara webhook Stripe, menggunakan @Convert khusus untuk menyimpan ke database MySQL sebagai kolom tipe json
(Itu harus mencakup kata kunci, umpan Google, dan menakut-nakuti siapa pun yang tidak peduli. )
pengantar
Saya memiliki aplikasi web yang sedang diperbarui untuk menggunakan API Pembayaran webhook Stripe (https. //garis. com/dokumen/webhook)
Aplikasi ini menggunakan Spring Boot, JPA dan MySQL. Pendekatannya adalah menyimpan callback acara webhook di database saat diterima
Ada perpustakaan Stripe Java (https. //github. com/stripe/stripe-java) yang dapat digunakan untuk permintaan panggilan balik acara webhook. Bagi banyak orang ini adalah pilihan yang layak dan mungkin lebih baik. Namun saya tidak memerlukan akses ke objek data yang kompleks dan lebih suka menangani lebih sedikit ketergantungan dan memutar domain/entitas saya sendiri. Caveat emptor dll
Callback acara webhook (https. //garis. com/docs/api/events) adalah payload JSON dengan atribut sederhana yang konsisten dan dua objek atribut kompleks, data dan request
Mendekati
Pendekatannya adalah deserialize permintaan JSON melalui Jackson ke objek domain tunggal, menyimpan objek data dan request sebagai objek java JsonNode dan tipe json di MySQL
Dengan mempertahankan struktur JSON, model domain dapat disederhanakan menjadi satu objek yang menangani semua kemungkinan jenis permintaan callback webhook acara, sementara data JSON masih tersedia untuk aplikasi di domain Java dan melalui kueri SQL-JSON
Trade-off dan Pertimbangan
Sekarang adalah saat yang tepat untuk membahas pertukaran dan pertimbangan
Tipe MySQL JSON tidak secepat tipe data yang lebih tradisional. Performa akan lambat saat menanyakan kolom jenis JSON vs tabel yang lebih tradisional dengan indeks yang disetel. (Saya tidak peduli jika itu menyakiti perasaan tim NoSQL, itu benar. )
Sintaks kueri untuk tipe JSON canggung. Jika kueri ekstensif direncanakan untuk data, pastikan untuk menguji kueri tersebut untuk performa. Untuk aplikasi ini, kekurangan tersebut tidak menjadi perhatian
Pustaka Stripe Java tampaknya direkayasa dengan baik dan mencakup semua seluk-beluk permintaan callback acara webhook. Jika Anda menemukan diri Anda menggunakan objek acara untuk logika bisnis yang kompleks, mungkin pendekatan yang lebih baik untuk menggunakan pustaka Stripe Java
Detail
Diuji dengan versi berikut
- Versi Jawa 1. 8. 0_111
- Boot Musim Semi 2. 2. 0. MELEPASKAN
- Fasterxml Jackson Core 2. 10. 0
- Server MySQL5. 7. 29
API untuk definisi objek permintaan callback event webhook
https. //garis. com/docs/api/events/object
Objek acara ini mendefinisikan objek entitas domain Java dan tabel database MySQL
Perpustakaan Jenis Hibernasi
Salah satu tantangannya adalah menemukan 'prior art' atau pemetaan implementasi dari objek Java JsonNode di entitas domain ke tipe json database MySQL. Saya berasumsi bahwa ini adalah masalah yang terpecahkan
Banyak pencarian mengarah ke posting Stack Overflow di mana solusinya adalah menggunakan perpustakaan Hibernate Types (https. //github. com/vladmihalcea/hibernate-types)
Saya menambahkan dependensi, Jenis, dan konfigurasi, tetapi tidak dapat menyelesaikan kesalahan berikut
SqlExceptionHelper: SQL Error: 3144, SQLState: 22001 SqlExceptionHelper: Data truncation: Cannot create a JSON value from a string with CHARACTER SET 'binary'.Meskipun saya yakin ini berfungsi untuk beberapa orang dan saya yakin saya membuat beberapa kesalahan bodoh di suatu tempat, saya akhirnya tidak menggunakan pustaka Hibernate Types
Solusi yang Diimplementasikan
Solusi yang diimplementasikan adalah konverter khusus sederhana, menerapkan konverter ke objek entitas domain Java dengan anotasi @Convert
Objek Entitas Domain Java
Langkah pertama adalah menentukan objek permintaan panggilan balik acara webhook. Objeknya adalah kelas yang sangat khas, satu hal yang perlu diperhatikan adalah anotasi // Imports ommitted /** * */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @Entity @Table(name = "WEBHOOK_EVENT") public class WebhookEvent { @JsonProperty("id") @Id @Column(name = "ID") private String id; @JsonProperty("object") @Column(name = "OBJECT") private String object; @JsonProperty("api_version") @Column(name = "API_VERSION") private String apiVersion; @JsonProperty("created") @Column(name = "CREATED") private Long created; @JsonProperty("data") @Column(name = "DATA") @Convert(converter = JsonNodeConverter.class) private JsonNode data; @JsonProperty("livemode") @Column(name = "LIVEMODE") @Type(type = "org.hibernate.type.NumericBooleanType") private boolean livemode; @JsonProperty("pending_webhooks") @Column(name = "PENDING_WEBHOOKS") private Integer pendingWebhooks; @JsonProperty("request") @Column(name = "REQUEST") @Convert(converter = JsonNodeConverter.class) private JsonNode request; @JsonProperty("type") @Column(name = "TYPE") private String type; // Constructors and accessors ommitted }0
// Imports ommitted /** * */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @Entity @Table(name = "WEBHOOK_EVENT") public class WebhookEvent { @JsonProperty("id") @Id @Column(name = "ID") private String id; @JsonProperty("object") @Column(name = "OBJECT") private String object; @JsonProperty("api_version") @Column(name = "API_VERSION") private String apiVersion; @JsonProperty("created") @Column(name = "CREATED") private Long created; @JsonProperty("data") @Column(name = "DATA") @Convert(converter = JsonNodeConverter.class) private JsonNode data; @JsonProperty("livemode") @Column(name = "LIVEMODE") @Type(type = "org.hibernate.type.NumericBooleanType") private boolean livemode; @JsonProperty("pending_webhooks") @Column(name = "PENDING_WEBHOOKS") private Integer pendingWebhooks; @JsonProperty("request") @Column(name = "REQUEST") @Convert(converter = JsonNodeConverter.class) private JsonNode request; @JsonProperty("type") @Column(name = "TYPE") private String type; // Constructors and accessors ommitted }_Implementasi AttributeConverter
Kelas JsonNodeConverter mengimplementasikan // Imports ommitted /** * */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @Entity @Table(name = "WEBHOOK_EVENT") public class WebhookEvent { @JsonProperty("id") @Id @Column(name = "ID") private String id; @JsonProperty("object") @Column(name = "OBJECT") private String object; @JsonProperty("api_version") @Column(name = "API_VERSION") private String apiVersion; @JsonProperty("created") @Column(name = "CREATED") private Long created; @JsonProperty("data") @Column(name = "DATA") @Convert(converter = JsonNodeConverter.class) private JsonNode data; @JsonProperty("livemode") @Column(name = "LIVEMODE") @Type(type = "org.hibernate.type.NumericBooleanType") private boolean livemode; @JsonProperty("pending_webhooks") @Column(name = "PENDING_WEBHOOKS") private Integer pendingWebhooks; @JsonProperty("request") @Column(name = "REQUEST") @Convert(converter = JsonNodeConverter.class) private JsonNode request; @JsonProperty("type") @Column(name = "TYPE") private String type; // Constructors and accessors ommitted }1, khususnya metode // Imports ommitted /** * */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @Entity @Table(name = "WEBHOOK_EVENT") public class WebhookEvent { @JsonProperty("id") @Id @Column(name = "ID") private String id; @JsonProperty("object") @Column(name = "OBJECT") private String object; @JsonProperty("api_version") @Column(name = "API_VERSION") private String apiVersion; @JsonProperty("created") @Column(name = "CREATED") private Long created; @JsonProperty("data") @Column(name = "DATA") @Convert(converter = JsonNodeConverter.class) private JsonNode data; @JsonProperty("livemode") @Column(name = "LIVEMODE") @Type(type = "org.hibernate.type.NumericBooleanType") private boolean livemode; @JsonProperty("pending_webhooks") @Column(name = "PENDING_WEBHOOKS") private Integer pendingWebhooks; @JsonProperty("request") @Column(name = "REQUEST") @Convert(converter = JsonNodeConverter.class) private JsonNode request; @JsonProperty("type") @Column(name = "TYPE") private String type; // Constructors and accessors ommitted }2 dan // Imports ommitted /** * */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @Entity @Table(name = "WEBHOOK_EVENT") public class WebhookEvent { @JsonProperty("id") @Id @Column(name = "ID") private String id; @JsonProperty("object") @Column(name = "OBJECT") private String object; @JsonProperty("api_version") @Column(name = "API_VERSION") private String apiVersion; @JsonProperty("created") @Column(name = "CREATED") private Long created; @JsonProperty("data") @Column(name = "DATA") @Convert(converter = JsonNodeConverter.class) private JsonNode data; @JsonProperty("livemode") @Column(name = "LIVEMODE") @Type(type = "org.hibernate.type.NumericBooleanType") private boolean livemode; @JsonProperty("pending_webhooks") @Column(name = "PENDING_WEBHOOKS") private Integer pendingWebhooks; @JsonProperty("request") @Column(name = "REQUEST") @Convert(converter = JsonNodeConverter.class) private JsonNode request; @JsonProperty("type") @Column(name = "TYPE") private String type; // Constructors and accessors ommitted }3. Kedua metode ini melakukan konversi antar domain, ke/dari JsonNode dan String. String kemudian dimasukkan ke dalam kolom MySQL json
package com.gordonturner.app.converter; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.util.StringUtils; import javax.persistence.AttributeConverter; public class JsonNodeConverter implements AttributeConverter<JsonNode, String> { private static final Logger logger = LoggerFactory.getLogger( JsonNodeConverter.class ); /** * @param jsonNode * @return */ @Override public String convertToDatabaseColumn(JsonNode jsonNode) { if( jsonNode == null) { logger.warn( "jsonNode input is null, returning null" ); return null; } String jsonNodeString = jsonNode.toPrettyString(); return jsonNodeString; } /** * @param jsonNodeString * @return */ @Override public JsonNode convertToEntityAttribute(String jsonNodeString) { if ( StringUtils.isEmpty(jsonNodeString) ) { logger.warn( "jsonNodeString input is empty, returning null" ); return null; } ObjectMapper mapper = new ObjectMapper(); try { return mapper.readTree( jsonNodeString ); } catch( JsonProcessingException e ) { logger.error( "Error parsing jsonNodeString", e ); } return null; } }_Basis data
Tabel database dikelola melalui liquibase, tetapi pernyataan SQL create untuk tabel webhook adalah
CREATE TABLE `WEBHOOK_EVENT` ( `ID` varchar(255) NOT NULL, `OBJECT` varchar(255) NOT NULL, `API_VERSION` varchar(20) NOT NULL, `CREATED` bigint(20) NOT NULL, `DATA` json DEFAULT NULL, `LIVEMODE` int(11) NOT NULL DEFAULT '0', `PENDING_WEBHOOKS` int(11) DEFAULT NULL, `REQUEST` json DEFAULT NULL, `TYPE` varchar(255) NOT NULL, PRIMARY KEY (`ID`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;Dua catatan yang perlu diperhatikan adalah // Imports ommitted /** * */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @Entity @Table(name = "WEBHOOK_EVENT") public class WebhookEvent { @JsonProperty("id") @Id @Column(name = "ID") private String id; @JsonProperty("object") @Column(name = "OBJECT") private String object; @JsonProperty("api_version") @Column(name = "API_VERSION") private String apiVersion; @JsonProperty("created") @Column(name = "CREATED") private Long created; @JsonProperty("data") @Column(name = "DATA") @Convert(converter = JsonNodeConverter.class) private JsonNode data; @JsonProperty("livemode") @Column(name = "LIVEMODE") @Type(type = "org.hibernate.type.NumericBooleanType") private boolean livemode; @JsonProperty("pending_webhooks") @Column(name = "PENDING_WEBHOOKS") private Integer pendingWebhooks; @JsonProperty("request") @Column(name = "REQUEST") @Convert(converter = JsonNodeConverter.class) private JsonNode request; @JsonProperty("type") @Column(name = "TYPE") private String type; // Constructors and accessors ommitted }_4 dan // Imports ommitted /** * */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @Entity @Table(name = "WEBHOOK_EVENT") public class WebhookEvent { @JsonProperty("id") @Id @Column(name = "ID") private String id; @JsonProperty("object") @Column(name = "OBJECT") private String object; @JsonProperty("api_version") @Column(name = "API_VERSION") private String apiVersion; @JsonProperty("created") @Column(name = "CREATED") private Long created; @JsonProperty("data") @Column(name = "DATA") @Convert(converter = JsonNodeConverter.class) private JsonNode data; @JsonProperty("livemode") @Column(name = "LIVEMODE") @Type(type = "org.hibernate.type.NumericBooleanType") private boolean livemode; @JsonProperty("pending_webhooks") @Column(name = "PENDING_WEBHOOKS") private Integer pendingWebhooks; @JsonProperty("request") @Column(name = "REQUEST") @Convert(converter = JsonNodeConverter.class) private JsonNode request; @JsonProperty("type") @Column(name = "TYPE") private String type; // Constructors and accessors ommitted }5, yang bertipe `json`
Permintaan JSON MySQL
Sebagai contoh cepat, berikut adalah cara memilih // Imports ommitted /** * */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @Entity @Table(name = "WEBHOOK_EVENT") public class WebhookEvent { @JsonProperty("id") @Id @Column(name = "ID") private String id; @JsonProperty("object") @Column(name = "OBJECT") private String object; @JsonProperty("api_version") @Column(name = "API_VERSION") private String apiVersion; @JsonProperty("created") @Column(name = "CREATED") private Long created; @JsonProperty("data") @Column(name = "DATA") @Convert(converter = JsonNodeConverter.class) private JsonNode data; @JsonProperty("livemode") @Column(name = "LIVEMODE") @Type(type = "org.hibernate.type.NumericBooleanType") private boolean livemode; @JsonProperty("pending_webhooks") @Column(name = "PENDING_WEBHOOKS") private Integer pendingWebhooks; @JsonProperty("request") @Column(name = "REQUEST") @Convert(converter = JsonNodeConverter.class) private JsonNode request; @JsonProperty("type") @Column(name = "TYPE") private String type; // Constructors and accessors ommitted }6 dari kolom // Imports ommitted /** * */ @JsonInclude(JsonInclude.Include.NON_NULL) @JsonIgnoreProperties(ignoreUnknown = true) @Entity @Table(name = "WEBHOOK_EVENT") public class WebhookEvent { @JsonProperty("id") @Id @Column(name = "ID") private String id; @JsonProperty("object") @Column(name = "OBJECT") private String object; @JsonProperty("api_version") @Column(name = "API_VERSION") private String apiVersion; @JsonProperty("created") @Column(name = "CREATED") private Long created; @JsonProperty("data") @Column(name = "DATA") @Convert(converter = JsonNodeConverter.class) private JsonNode data; @JsonProperty("livemode") @Column(name = "LIVEMODE") @Type(type = "org.hibernate.type.NumericBooleanType") private boolean livemode; @JsonProperty("pending_webhooks") @Column(name = "PENDING_WEBHOOKS") private Integer pendingWebhooks; @JsonProperty("request") @Column(name = "REQUEST") @Convert(converter = JsonNodeConverter.class) private JsonNode request; @JsonProperty("type") @Column(name = "TYPE") private String type; // Constructors and accessors ommitted }4
SELECT ID, DATA->"$.object.status" AS `OBJECT` FROM `WEBHOOK_EVENT`;Kesimpulan
Penemuan cara memetakan dari JsonNode di domain Java ke tipe json MySQL agak sulit
Saya puas dengan hasilnya sambil meminimalkan perpustakaan pihak ketiga. Saya suka ide untuk menjaga objek JSON 'kompleks' tetap utuh sambil memetakan atribut respons sederhana dasar