Entitäten in Geschäftsanwendungen
Bei der Entwicklung von Geschäftsanwendungen spielen Entitäten eine wichtige Rolle für mich. Bevor ich mit der Entwicklung beginne, denke ich zunächst über die beteiligten fachlichen Entitäten und ihre Beziehungen zueinander nach. Ein Entity-Relationship-Modell ist dabei ein nützliches Hilfsmittel, um die Beziehungen zwischen Entitäten zu beschreiben und grafisch darzustellen. Eine häufige Beziehung zwischen Entitäten ist die n:m-Beziehung. Bei einer Anwendung wie bspw. einer Aufgabenliste kann eine Aufgabe bspw. beliebig viele Labels haben, um die Aufgabe anhand der Labels zu kategorisieren. Aus Sicht eines Labels kann ein Label beliebig vielen Aufgaben zugewiesen sein. Aufgabe und Label stehen in einer n:m-Beziehung zueinander. Häufig wird dafür auch die Bezeichnung Many-to-Many-Beziehung verwendet.
Ein passender Technologie-Stack
Bei der Entwicklung von Geschäftsanwendungen ist es wichtig, dass die verwendeten Frameworks diese n:m-Beziehung möglichst komfortabel und flexibel unterstützen. Bei der Entwicklung von Geschäftsanwendungen mit Hilla werden diese Anforderungen sehr gut erfüllt.
Im Backend kommt Spring Boot zum Einsatz. In Verbindung mit relationalen Datenbanken kann man Spring Data JPA einsetzen und dabei viele Vorteile nutzen, dazu zählen unter anderem:
- Das Datenbank-Schema kann automatisch aus den Klassen der Entitäten generiert werden.
- Die Kommunikation mit der Datenbank kann über die sehr mächtigen JPA-Repositories erfolgen.
Im Frontend kann man die Anwendung mit Hilfe der fertigen UI-Komponenten von Hilla sehr schnell entwickeln. So kann ein typisches Master-Detail-UI-Pattern mit Hilfe der Komponenten AutoGrid und AutoForm schnell umgesetzt werden.
Hilla unterstützt die 1:1-Beziehungen von Entitäten in der AutoGrid
-Komponente bereits Out-of-the-box (siehe Beispiel). Für n:m-Beziehungen kann man sowohl die AutoGrid
- als auch die AutoForm
-Komponente leicht anpassen, um die Beziehung sinnvoll zu unterstützen.
Besonderheiten der n:m-Beziehung
Eine Besonderheit der n:m-Beziehung ist, dass sie mit Hilfe einer Mapping-Tabelle aufgelöst werden muss, wenn man sie in einem relationalen Datenbanksystem abbilden möchte. Andernfalls müsste man gegen die 1. Normalform (1NF) verstoßen. Die n:m-Beziehung zwischen einer Aufgabe und einem Label lässt sich folgendermaßen in einem relationalen Modell abbilden:
Beispiel
Im nachfolgenden Beispiel soll dies anhand der Entitäten Task
und Label
gezeigt werden. Eine Aufgabe kann beliebig viele Labels besitzen und ein Label kann beliebig vielen Aufgaben zugeordnet sein.
Backend
Im Backend werden zunächst die beiden Entitäten Task
und Label
erstellt.
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
Long id;
@NotBlank
private String name;
@ManyToMany
@JoinTable(name = "tasks_labels")
private List<Label> labels;
// Getter and Setter omitted
}
Anhand der Annotation @JoinTable(name = "tasks_labels")
wird die Mapping-Tabelle spezifiziert, über die die n:m-Beziehung aufgelöst wird. Spring Data JPA kümmert sich automatisch darum, diese Mapping-Tabelle anzulegen und zu verwalten.
@Entity
@Table(name = "labels")
public class Label {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
Long id;
@NotBlank
@Column(name = "val")
private String value;
// Getter and Setter omitted
}
Es wäre auch möglich die n:m-Beziehung bi-direktional abzubilden und auch in der Entität Label
eine Liste von Aufgaben vorzuhalten. Für das Beispiel soll es jedoch genügen, nur eine uni-direktionale n:m-Beziehung aus Sicht der Aufgabe zu verwalten.
Im Anschluss werden die zugehörigen JPA-Repositories erstellt.
public interface TaskRepository extends JpaRepository<Task, Long>, JpaSpecificationExecutor<Task> { }
public interface LabelRepository extends JpaRepository<Label, Long>, JpaSpecificationExecutor<Label> { }
Die Kommunikation zwischen Backend und Frontend erfolgt über entsprechende Services für Task
und Label
, die mit @BrowserCallable
annotiert werden. Dies genügt, damit Hilla die erforderlichen TypeScript-Klassen generiert, die man anschließend im Frontend verwenden kann.
@BrowserCallable
@AnonymousAllowed
public class TaskService extends CrudRepositoryService<Task, Long, TaskRepository> { }
@BrowserCallable
@AnonymousAllowed
public class LabelService extends CrudRepositoryService<Label, Long, LabelRepository> {
public List<Label> getLabels() {
return super.getRepository().findAll();
}
}
Frontend
Die Master-View der Beispiel-Anwendung zeigt alle Aufgaben in der AutoGrid
-Komponente. In der Tabelle ist eine Spalte für die Labels vorgesehen. Im Standard wird diese Spalte nicht angezeigt, da die AutoGrid
-Komponente keinen Default-Renderer für ein Feld vom Typ List
vorsieht. Daher wird die Spalte für Label über die Eigenschaft visibleColumns
explizit eingeblendet. Mit Hilfe eines eigenen Renderes kann man nun flexibel bestimmen, wie die Labels einer Aufgabe angezeigt werden sollen. Im Beispiel werden alle Label in einer Komma-separierten Liste angezeigt.
<AutoGrid
columnOptions={{
labels: {
renderer: ({ item }: { item: Task }) => {
const { labels } = item;
return <span>{labels?.map((label) => label?.value).join(", ")}</span>
},
}
}}
model={TaskModel}
service={TaskService}
visibleColumns={["name", "labels"]}
/>
Die AutoGrid
-Komponente verfügt über einen tollen Filter-Mechanismus für jede Spalte. Die Spalte für die Labels erhält einen individuellen Filter.
<AutoGrid
columnOptions={{
labels: {
headerFilterRenderer: ({ setFilter }) => (
<TextField
theme="small"
placeholder="Filter..."
onValueChanged={({ detail: { value } }) =>
setFilter({
propertyId: "labels.value",
filterValue: value,
matcher: Matcher.CONTAINS,
"@type": "propertyString",
})
}
/>
),
renderer: ({ item }: { item: Task }) => {
const { labels } = item;
return <span>{labels?.map((label) => label?.value).join(", ")}</span>
},
}
}}
model={TaskModel}
service={TaskService}
visibleColumns={["name", "labels"]}
/>
Die Detail-View der Anwendung zeigt eine Aufgabe in einer AutoForm
-Komponente, so dass eine Aufgabe erstellt und bearbeitet werden kann. Auch hier kann das Feld für die Labels individuell konfiguriert werden. Im vorliegenden Beispiel bietet es sich an, die Label über eine Multi-Select Combo Box-Komponente darzustellen. Diese Komponente ermöglicht die Auswahl mehrerer Labels, inkl. einer hilfreichen Filtermöglichkeit.
const { taskId } = useParams();
const task = useSignal<Task | undefined>(undefined);
const labels = useSignal<Label[]>([]);
useEffect(() => {
if (taskId) {
TaskService.get(Number.parseInt(taskId)).then((t) => task.value = t);
}
LabelService.getLabels().then((l) => labels.value = l);
}, [taskId])
return (
<AutoForm
fieldOptions={{
labels: {
renderer: ({ field }) => <MultiSelectComboBox {...field} items={labels.value} itemIdPath="id" itemValuePath="value" itemLabelPath="value" />
}
}}
item={task.value}
model={TaskModel}
service={TaskService}
visibleFields={["name", "labels"]}
/>
)
Alle verfügbaren Label werden über den LabelService
geladen und stehen in der Multi-Select Combo Box zur Auswahl zur Verfügung.
Fazit
Die Kombination aus Spring Boot und Spring Data JPA im Backend und der UI-Komponenten und der Code-Generierung von Hilla im Frontend ermöglichen eine sehr produktive und gleichzeitig sehr flexible Entwicklung von Geschäftsanwendungen. Auch der Umgang mit n:m-Beziehungen zwischen Entitäten fällt dabei leicht.