-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[feature]:implement transaction #36
base: v2
Are you sure you want to change the base?
Conversation
@@ -213,3 +215,26 @@ func EncodeCursor(t time.Time) string { | |||
|
|||
return base64.StdEncoding.EncodeToString([]byte(timeString)) | |||
} | |||
|
|||
func (m *mysqlArticleRepository) WithTransaction(f func(repo article.Repository) error) error { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @philchia , thanks for the PR.
I have a question, have you tried with context?
I have another approachment,
I'm using context, then insert the tx
to Context.
So generally, my function will look like this,
func (m * mysqlArticleRepository) WithTransaction(ctx context.Context, fn func(c context.Context) error) (err error) {
tx, err := m.DB.BeginTx(ctx, nil)
if err != nil {
return
}
_, err = tx.ExecContext(ctx, "SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
if err != nil {
return
}
c := context.WithValue(ctx, "TransactionContextKey", tx)
err = fn(c)
if err != nil {
if errTX := tx.Rollback(); errTX != nil {
logrus.Error("failed to rollback transaction", errTX)
}
return
}
if errTX := tx.Commit(); errTX != nil {
logrus.Error("failed to commmit transaction", errTX)
}
return
}
Usage in usecase/service:
err = s.repo.WithTransaction(ctx, func(c context.Context) (err error) {
err = s.repo.Create(c, article)
if err != nil {
return
}
err = s.authorRepo.Create(c, author)
return
})
But, the cons, maybe a bit redundant jobs, we need to check the tx
in ctx.
Like this.
func getTransactionFromCtx(ctx context.Context) (*sql.Tx, bool) {
tx, ok := ctx.Value("TransactionContextKey").(*sql.Tx)
return tx, ok
}
func (m *mysqlArticleRepository) Insert(ctx context.Context, article Article)error{
var tx *sql.Tx
tx, isTxFromCtx := getTransactionFromCtx(ctx)
if !isTxFromCtx {
tx, err = r.DB.BeginTx(ctx, nil)
if err != nil {
return
}
}
tx.ExecContext(ctx, "INSERT article SET title=?", article.Titlle)
//....
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi @bxcodec, thanks for this architecture.
But I would like to object to the use of context to convey something not explicitly.
I think it’s worthwhile either to wrap the transaction (which is suggested in this PR), or to transfer the transaction explicitly.
Also, as an option, it may just be worth creating a method in which there will be a transaction inside (for example CreateArticleWithAuthor).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hi @ghostiam,
Have you tried with the submitted PR, is there any issue so far?
Maybe related to memory and performance?
Or anything?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Transferring transaction explicitly is something I don't want to do ideally.
Since, the use case layer (who will use the repository), shouldn't know anything about the transaction. It's only know about calling repository.
What if, the repository using NoSQL, which is doesn't support transactions?
Let's say like MongoDB in the early stage, that still doesn't support transaction?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are plenty of possible business reasons why you would need to batch multiple repositories into one transaction though. By not passing the tx
in explicitly, you will prevent the developer from being able to do this.
NoSQL and SQL databases are pretty different, if you want to have both persistence layers be supported by the repository pattern, you'll only be able to use the features that are available to both types of databases, meaning you won't be able to support transactions.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@nii236 I also agree here. But this "violates" the "clean" architecture since the use case layer knows about the implementation details.
It can be done without braking the "clean" rule, if you abstract a interface for repositories that has Begin()
, Commit()
, Rollback()
.
Doing this, the use case layer won't know any details about how repositories implement, and still has the power to control transaction.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My 2cents. I fully agree with @bxcodec. Usecases should not know about transactions at all. Tx are a repo thing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@henrylusjen do you have an example, a gist?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My 2cents. I fully agree with @bxcodec. Usecases should not know about transactions at all. Tx are a repo thing.
I want to point out that we are talking about two different kinks of the transaction. One is the "DB" transaction, another one is business logic transaction (multiple work should do together).
Yes, usecase should not know about "DB" transaction at all. But like @nii236 said, it should have the power to raise "Business" transaction, and it's much complicated because it usually involve multiple repos and sometimes MQ or external service.
There's a solution called "UnitOfWork", but golang doesn't have any ORM support it yet. You might have to build it yourself.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK. Understood. Thanks.
What do you think about frederikhors@2ec0f2d?
@@ -14,4 +14,5 @@ type Repository interface { | |||
Update(ctx context.Context, ar *models.Article) error | |||
Store(ctx context.Context, a *models.Article) error | |||
Delete(ctx context.Context, id int64) error | |||
WithTransaction(func(Repository)error) error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder, how we use this in usecase/service layer?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
err = s.repo.WithTransaction( func(repo aritcle.Repository) error {
if err := repo.Create(c, article); err != nil {
return err
}
return nil
})
use it like this, its more clear
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah... I see this more simple.
But how do you handle like transaction across repositories.
Right now, it's okay since it's still the same article (in one repository)
How about across to another repository, like to author repository, or maybe publisher repository, category repository?
Let's say, I want to store article, including the category, the author, then the publisher?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think one transaction should only change one aggregate root, and we should have only one repo for one aggregate root.
if your operation across other aggregate root, try eventual consistency instead of strong consistency
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By saying that, so will you use a pub/sub pattern for that from the app level. Or from infra levels like using 3rd party message broker, and pubsub?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@r3code
This is new. This will decouple everything. Thanks for the idea.
What do you think @philchia?
*anyway, I'll keep this PR open. Since there's a lot of options to do the transactions here. If someone in the future has another idea, just keep posting on this PR. Just let's make this as a knowledge base.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would you use the same transaction across multiple repositories?
The way the okFunc
and panicFunc
works in the linked test is that it runs the repository func with the transaction then commits or rollbacks based on a panic after only one query.
An ideal implementation would allow the transaction to be committed or rolled back after multiple uses across multiple repositories, at the discretion of the developer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also got some inspiration from https://medium.com/@jfeng45/go-microservice-with-clean-architecture-transaction-support-61eb0f886a36,
I really liked it where it states that transaction logic should be applied to the use case layer, and not the persistence layer.
Basically, what he did is to create another wrapper around sql.DB and sql.Tx
https://gist.github.com/adamfdl/49da2b850991c59939da2426fa85c1ab
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the idea @adamfdl
I'm thinking of a general-purpose of architecture here. Luckily you use SQL which RDBMS here that supports the transaction features so that we can wrap the sql.DB and sql.Tx
But for RDBMS, I think this a good solution.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@adamfdl can you post a more complete example, please? How to use Wrap()
in a usecase for example? Thanks, really.
@bxcodec I love Golang and your project. Is there any news on this? No pressure. 😄 |
Hi @frederikhors , So yeah, I keep this PR open, so everyone can see all the opinions by the other. I want to make this PR just like a reference and any new golang engineer can choose which evil side they prefer. Every opinion is valid from anyone's perspective. So since this a public PR, I don't wanna choose one and merge it, unless if it's for my private project I'll choose one and convince my team about it. |
@bxcodec thanks for your answer. Since I don't know how to decide (because I'm newbie and because they all seem not 100% correct to me) I can ask you which method do you prefer? I need to use a transaction:
|
@bxcodec what do you think about this? I don't know how to do this: //I can also create an helper like this:
//dbConn := db.GetTxFromCtxOrUseDBConn(ctx, m.Conn)
//instead of the below:
var dbConn interface{} // I think I need an interface for *sql.DB & *sql.Tx here
tx, ok := repository.GetTransactionFromCtx(ctx)
if ok {
dbConn = tx
} else {
dbConn, _ = m.Conn.BeginTx(ctx, nil)
} Is that what you thought? To use context like this to handle transactions? |
If you have to use transactions across multiple repositories, it doesn't make sense to put it behind an interface anyway. Unless you'll only be dealing with different flavours of SQL databases. |
@nii236 I don't understand. Can you please explain it better? Thanks. |
I would try to explain what is meant by @nii236 , the idea of defining a repository as an interface is because we're trying to make it pluggable/changeable by following the principle of Dependency Inversion, but if we use a transaction moreover if it's across multiple repositories, it would be impossible for our system to change our dependency from a SQL database into another kind of repository (NoSQL, Rest API, GRPC, or any other kind) because the concept of atomic transaction is only exist on SQL Database, then if we define those repository as an interface it would be useless because we're preparing for something that would never happen |
This blog post explains my thinking: https://threedots.tech/post/common-anti-patterns-in-go-web-applications/ Scroll down to "Starting with the database schema". |
No description provided.