jwstegemann / fritz2

Easily build reactive web-apps in Kotlin based on flows and coroutines.
https://www.fritz2.dev
MIT License
664 stars 29 forks source link

Full Stack Example #74

Closed jwstegemann closed 3 years ago

jwstegemann commented 4 years ago

We need an example sharing model- and validation-code between front and backend and shows how to packe and deploy the combination in

tblakers commented 4 years ago

Assume this still needs doing? Looking to evaluate Fritz2 for a project with Spring Boot backend, so happy to do that by contributing an example...

jwstegemann commented 4 years ago

We would be glad to get some help on that! @jamowei is working on a ktor-backed variant of the TodoMVC right now. I will provide some special Stores supporting LocalStorage, Rest and WebSockets.

Using Spring it looked like a problem to annotate the data classes in common main according to JPA. This might be the way to go: https://github.com/CesareIurlaro/Klinika/commit/76a49c304a77dad7b190bcb483215afd04578531 (just in case you want to use JPA...=

jamowei commented 4 years ago

@tblakers you can take a look at my current state of the fritz2 full-stack-examples here:

Currently there are a few things missing in fritz2 for getting the TodoMVC full-stack-examples done:

I also plan to add some fritz2 validations to the TodoMVC example for proving the feature, that you write it once (in commonMain) and use it in front- and backend accordingly.

I discovered some difficulties with using spring-boot so far:

We are working on this and add the missing features in the next version of fritz2. Our goal is to provide some new out-of-the-box working fritz2-templates for ktor & spring-boot. ;-)

tblakers commented 4 years ago

@jamowei I'll fork your spring repo and get started.

Regarding the JPA annotation problem, in a production app I wouldn't normally want to ship my persistence entities all the way to the client. Is the motivation to reduce boilerplate mapping code, or could annotations like @Id be beneficial for fritz2?

Regarding using kotlinx.serialization in Spring, I'll give this project a whirl - it's webflux only, but that seems OK. https://github.com/markt-de/spring-kotlinx-serialization

hpehl commented 4 years ago

I'd like to add Quarkus / Jakarta EE to the list of supported backends. I could try to provide such a full stack example.

tblakers commented 4 years ago

Made some reasonable progress, would be great to make the current model state available in apply {} as you suggest, so that we can save the result. If I get that, then I'll integrate validation into the front and backend.

Haven't yet made a serious attempt to get kotlinx serialization working in Spring - for now bridging with Jackson, but sounds like this will become official in the next few months.

At an architectural level, interested in your thoughts on the "Editing" property on the ToDo Model. As this property is only used in the UI, and is very component-specific, it doesn't seem ideal to include it in the common model, as there would be dozens of similar properties across a medium-size app. A couple of options occurred to me so far, but neither is attractive:

Suggestions appreciated...

jamowei commented 4 years ago

Regarding the JPA annotation problem, in a production app I wouldn't normally want to ship my persistence entities all the way to the client. Is the motivation to reduce boilerplate mapping code, or could annotations like @id be beneficial for fritz2?

@tblakers the motivation is to reduce boilerplate mapping code, e.g. when you use validation which works on your data classes in commonMain and also needs the generated lenses to provide the ids for your validation messages. If you use separate data classes for it you must map them every time.

jamowei commented 4 years ago

@tblakers

At an architectural level, interested in your thoughts on the "Editing" property on the ToDo Model. As this property is only used in the UI, and is very component-specific, it doesn't seem ideal to include it in the common model, as there would be dozens of similar properties across a medium-size app. A couple of options occurred to me so far, but neither is attractive:

  • Have a wrapper class that adds the additional properties. e.g. data class ToDoEntry(val toDo: ToDo, val editable: Boolean). Can make the Id available in the wrapper via delegation to the ToDo object, but that doesn't work with the current lense code generation.
  • Separate model classes for use in the table, duplicating the properties. This will work, but too much boilerplate, and we lose the ability to share domain logic with the client.

Suggestions appreciated...

You can use the @Transient annotation for that. Then the field will not be serialized by kotlinx.serialization. I updated my fritz2-ktor-example accordingly.

Here is my current model:

@Serializable
@Lenses
data class ToDo(
    val id: String = uniqueId(),
    val text: String = "",
    val completed: Boolean = false,
    @Transient
    val editing: Boolean = false
)

And here is my table definition in backend:

object ToDos : LongIdTable() {
    val text = varchar("text", 255)
    val completed = bool("completed")
}
jamowei commented 4 years ago

@tblakers

Made some reasonable progress, would be great to make the current model state available in apply {} as you suggest, so that we can save the result. If I get that, then I'll integrate validation into the front and backend.

@jwstegemann found a possible solution for that problem. I have successfully tested this approach in my fritz2-ktor-todomvc project. Here is the code snipped from my project:

val toDos = object : RootStore<List<ToDo>>(listOf(), dropInitialData = true) {
...
        val toggleAll = apply<Boolean, List<ToDo>> { toggle ->
            data.take(1).flatMapConcat { toDos -> // data.take(1).flatMapConcat gives you the current model from store ;-)
                api.contentType("application/json")
                    .body(Json.stringify(serializer.list, toDos.map { it.copy(completed = toggle) }))
                    .put().onErrorLog().body().map {
                        Json.parse(serializer.list, it)
                    }
            }
        } andThen update

}

For our next release version 0.7 of fritz2, which will hopefully come in a couple of weeks, we want to provide some new store extensions for synchronizing stores's data with several external services, e.g. Restfull-API, Websocket, LocalStorage etc. Then this code gets much cleaner and concise. Currently @jwstegemann and I are working on it. But for now you can use the provided code snipped above for your example. Maybe you will find a better solution. If that the case please let us know ;-)

If you like to share some further ideas or thoughts you can also contact us at Slack or Discord (links are at our README.md in the badges).

tblakers commented 4 years ago

You can use the @Transient annotation for that. Then the field will not be serialized by kotlinx.serialization.

Yep, the issue not preventing the database from serialising a value, but rather I'd like to avoid populating the common model with transient state fields required by widgets in the frontend. There will rapidly be a lot of these in any non-trivial app, and they will make the common model unwieldy on the backend.

Roughly speaking, I'd like to be able to attach a "sidecar" of state to each instance of the common model class that is accessible within the context of a Store. This way, transient fields don't escape the context in which they are used. I can add this sidecar right now by using a wrapper class and a custom lense implementation, but DIY-lenses obviously not a sustainable approach.

tblakers commented 4 years ago

the motivation is to reduce boilerplate mapping code, e.g. when you use validation which works on your data classes in commonMain and also needs the generated lenses to provide the ids for your validation messages. If you use separate data classes for it you must map them every time.

Yeah I certainly want to share the core domain objects between front and backend, so that domain, validation logic etc can be shared as you say. But often the requirements of a persistence object are quite different to a domain object. Both in terms of object layout, but also in terms of the classes used. e.g in my demo project, I've introduced a sealed class to track the completion status.

@Lenses
@Serializable
data class ToDo(
        val id: String = "-1",
        val text: String = "",
        val status: ToDoStatus = Uncompleted,
        val editing: Boolean = false
)

@Lenses
@Serializable
sealed class ToDoStatus

/**
 * We use the WrappedDateTime class from Klock, because it kotlinx.serialization doesn't yet support inline classes
 */
@Lenses
@Serializable
data class Completed(
        @Serializable(with = DateTimeSerializer::class) val completedOn: WDateTime
): ToDoStatus() {

        companion object {
                fun now() = Completed(WDateTime.now())
        }
}

@Lenses
@Serializable
object Uncompleted: ToDoStatus()

I'm also using the "Klock" library, so that domain objects can reference time in a multiplatform compatible way. But both of those things are a bit of a nuisance in JPA. Could certainly write serialization code at the JPA level to handle these things, but I prefer to just map between domain and persistence objects so that I'm not tempted to compromise my domain model due to JPA limitations :-)

I tried to join the Slack workspace, but think it might require invites?

jamowei commented 4 years ago

Roughly speaking, I'd like to be able to attach a "sidecar" of state to each instance of the common model class that is accessible within the context of a Store. This way, transient fields don't escape the context in which they are used. I can add this sidecar right now by using a wrapper class and a custom lense implementation, but DIY-lenses obviously not a sustainable approach.

In larger projects I personelly would use different stores for that. So you should seperate your data in a domain specific way. E.g you then create an ui-store which is only used by the client and controlls the behavings of your UI. Next to this I you create your domain specific data-stores. You can then combine these resulting data flows of those stores with the following flatMap{ map{...}} construct:

val firstName = object: RootStore<String>("Foo") {}
val lastName = object: RootStore<String>("Bar") {}

render {
    p {
        firstName.data.flatMapConcat { firstName ->
            lastName.data.map { lastName ->
                "Your full name is: $firstName $lastName"
            }
        }.bind()
    }
}.mount("target")

That is the part for the one-way-databinding. If you want to update your stores data with informations from other stores you see how to achieve this in our documentation

Of course your code getting then a bit more complex but it stays clean and reactive. In my opinion it is very common to use a lot of small and single scoped stores with different data classes and connect them together so that they can interact in a complex way to achieve your goal. It is the same concept like in the micoservices world. This way you are very flexible and domain orientated. Hopefully I understand you correct. Otherwise you maybe can provide some example code.

We are currently working on these things and try to cover all use cases with our stores (two-way-databinding). Of course there is a bit of lacking on this parts in our documentation. If you want help us on that you can create some PRs for the doc-repo.

All in all we are very glad that you share your ideas and use cases so that we can think about it and see if we are missing something :-)

jwstegemann commented 4 years ago

@tblakers Please send an email to info@fritz2.dev and I will invite you to slack (no open channels there).

tblakers commented 4 years ago

Have implemented a basic version that integrates with Spring Boot. Still a few things missing, including getting Spring to use kotlinx.serialization, but Jackson works OK with a bit of manual serialization code and sounds like this will be supported natively in short-order.

This implementation uses two stores as recommended above, one for the common model, and one for ui-specific state. I found using the combine() operator to merge the flows worked reliably, tho doesn't seem very efficient. In general can't say I'm a big fan of the two-store-and-combine approach, because of the boilerplate, but also because there is a race condition when one event is processed by the UI before the compensating change is made in the UI-store.

Sounds like a few things are changing in 0.7, so I'll revisit then and see if there's a better approach.

Am happy to make changes so it's done the "fritz2 way".

tblakers commented 4 years ago

https://github.com/jamowei/fritz2-spring-todomvc/pull/1

jamowei commented 4 years ago

jamowei/fritz2-spring-todomvc#1

Thank you very much. I will review it in the next couple of days 👍

jamowei commented 4 years ago

Hi, today I had some time to finish my first versions of the two full-stack-examples:

They are both running as expected and for this I use the latest pre-release build of fritz2 (0.7-SNAPSHOT) which contains a lot of new features for improving these full-stack-examples. If you also want to use the lastest pre-release of fritz2 you can take a look here for setting this up.

Please feel free to review my code an give me some hints what I can do better ;-) @jwstegemann is currently working at new stores which will make the handling with remote APIs much easier in fritz2. When they are available I will use them in both examples. So stay tuned!

jamowei commented 3 years ago

Ktor-Full-Stack example added to fritz2-examples page!