grpc-swift
grpc-swift copied to clipboard
Setup for establishing connection(s) in a multi-service client application using v2
What are you trying to achieve?
I am building an iOS app for a complex product that speaks to multiple logical GRPC services, all served from the same server. So, for example, I can call UserService.GetUser() and ProgramService.GetProgram() and they will both go to the same (load-balanced, horizontally scaled) host.
Every example I've seen for usage of Swift GRPC 2 is for a one-off single call to a single service, looking like:
try await withGRPCClient(
transport: .http2NIOPosix(
target: .dns(host: hostname, port: 443),
transportSecurity: .tls
)
) { client in
let greeter = GreetingService.Client(wrapping: client)
let greeting = try await greeter.sayHello(.with { $0.name = "swift.org" })
print(greeting.message)
}
I'd like to understand best practices for usage with multiple services, within an actual app that needs a connection to function. A couple of options spring to mind:
- Create a new GRPCClient for each service client.
- Create a single GRPCClient and then re-use that for each service client.
- Create a single GRPCClient that has some kind of pool behind it (of say, 4 connections), then re-use that single exposed GRPCClient for each service client.
Option 2/3 is most similar to what I had for Swift GRPC 1, and my preferred approach if possible. (There are already a dozen services, and there will be more in the future.)
I would also like to understand if there's a blocking/synchronous approach to ensuring a connection is established.
What have you tried so far?
Previously, I had a single synchronously established GRPCChannel, exposed as a static computed variable:
class GRPCManager {
private static var host: String {
Bundle.main.infoDictionary?["APP_CORE_HOST"] as! String
}
static var connection: GRPCChannel {
guard host != "" else {
fatalError("GRPC Host is not set")
}
return connect()
}
private static func connect() -> GRPCChannel {
let eventLoopGroup = NIOTSEventLoopGroup(loopCount: 1, defaultQoS: .default)
return ClientConnection.usingPlatformAppropriateTLS(for: eventLoopGroup)
.connect(host: host, port: 443)
}
}
and then each services had an init like:
init() {
client = App_UserServiceAsyncClient(channel: GRPCManager.connection)
}
init() {
client = App_ProgramServiceAsyncClient(channel: GRPCManager.connection)
}
This lazily established the connection the first time it was needed, requiring no explicit setup in the @main App. It seemed to work well. I appreciate that client.runConnections() is now asynchronous, but I need to know when it's reliably available/block until the connection is established, because the app is unusable without it.
I would like to achieve this while keeping the service clients themselves isolated and independent of each other, in particular not requiring a specific order of service usage.
Every example I've seen for usage of Swift GRPC 2 is for a one-off single call to a single service, looking like:
try await withGRPCClient( transport: .http2NIOPosix( target: .dns(host: hostname, port: 443), transportSecurity: .tls ) ) { client in let greeter = GreetingService.Client(wrapping: client) let greeting = try await greeter.sayHello(.with { $0.name = "swift.org" }) print(greeting.message) } I'd like to understand best practices for usage with multiple services, within an actual app that needs a connection to function. A couple of options spring to mind:
- Create a new GRPCClient for each service client.
- Create a single GRPCClient and then re-use that for each service client.
- Create a single GRPCClient that has some kind of pool behind it (of say, 4 connections), then re-use that single exposed GRPCClient for each service client.
(2) is the correct option here: GRPCClient should be re-used across services hosted by the same server. (3) is effectively the same as (2) as the underlying NIO based transport will have a pool of connections behind it. (This depends on how you configure it, although many applications won't need client-side load balancing and a single backing connection will suffice).
Option 2/3 is most similar to what I had for Swift GRPC 1, and my preferred approach if possible. (There are already a dozen services, and there will be more in the future.)
I would also like to understand if there's a blocking/synchronous approach to ensuring a connection is established.
In v1 and v2 creating the channel object doesn't actually create a connection to the remote peer. In v1 a connection(s) are created asynchronously when the first RPC is started. In v2 connections are creating asynchronously when you call runConnections(). You shouldn't worry about waiting blocking until this happens; RPCs will be queued until a connection becomes ready (or the RPC times out). Hope that helps!
@glbrntt Thanks for the info. I'm struggling to get this to work. I have a GRPC Manager:
import Foundation
import GRPCCore
import GRPCNIOTransportHTTP2Posix
enum GRPCManager {
static var client: GRPCClient<HTTP2ClientTransport.Posix> {
do {
return try GRPCClient(
transport: .http2NIOPosix(target: .ipv4(host: "127.0.0.1"), transportSecurity: .plaintext)
)
} catch {
fatalError("Failed to create GRPC client: \(error)")
}
}
static func connectTask() {
Task {
do {
print("Connecting GRPC...")
try await client.runConnections()
} catch {
print("Error connecting \(error)")
}
}
}
}
and a remote provider service that uses that :
import GRPCNIOTransportHTTP2Posix
import SwiftProtobuf
final class RemoteMobileProvider: MobileProviding, Sendable {
private let client: App_MobileService.Client<HTTP2ClientTransport.Posix>
init() {
client = App_MobileService.Client(wrapping: GRPCManager.client)
}
func getAppConfig() async throws -> AppConfig {
do {
print("In getAppConfig...")
let response = try await client.getAppConfig(Google_Protobuf_Empty())
print("appConfig Response \(response)")
return AppConfig(response.config)
} catch {
print("Error \(error)")
throw ServiceError.from(error)
}
}
}
which are used from the the main app file:
WindowGroup {
RootView()
.onAppear(perform: {
GRPCManager.connectTask()
configViewModel.loadConfigTask()
})
(configViewModel.loadConfigTask just asynchronously refreshes the config from the remote service by calling RemoteMobileProvider.getAppConfig() from a Task).
I compiles and runs, but when I start up the app in the simulator, I see:
Connecting GRPC...
In getAppConfig...
and then there is no more output. There's no appConfig Response. There's no obvious error connecting. I can't see any timeouts, no obvious failures of any kind. It just seems to block in perpetuity. I've confirmed the GRPC server is running, but even when it's not, I don't get any feedback.
I'm sure I'm missing something obvious, but I can't figure it out. Any ideas?
Eventually got it working by calling client.runConnections in the initial computed client property. Don't fully understand why this is ok and the other wasn't. Perhaps it's something to do with it being computed once and then not permitting any changes? 🤷🏼 Anyway, this worked:
static var client: GRPCClient<HTTP2ClientTransport.Posix> {
do {
let client = try GRPCClient(
transport: .http2NIOPosix(target: .dns(host: host, port: 443), transportSecurity: .tls)
)
Task {
try await client.runConnections()
}
return client
} catch {
fatalError("Failed to create GRPC client: \(error)")
}
}
This returns a new GRPCClient instance every time you use client which is why your first example didn't work, your connectTask() was using a different instance to getAppConfig.
enum GRPCManager {
static var client: GRPCClient<HTTP2ClientTransport.Posix> {
do {
return try GRPCClient(
transport: .http2NIOPosix(target: .ipv4(host: "127.0.0.1"), transportSecurity: .plaintext)
)
} catch {
fatalError("Failed to create GRPC client: \(error)")
}
}
}
Ahh, I see! So perhaps this is more appropriate:
static let client: GRPCClient<HTTP2ClientTransport.Posix> = {
do {
let client = try GRPCClient(
transport: .http2NIOPosix(target: .dns(host: host, port: 443), transportSecurity: .tls)
)
Task {
try await client.runConnections()
}
return client
} catch {
fatalError("Failed to create GRPC client: \(error)")
}
}()
Yes, that would be more appropriate.