跳转到内容

一道 gRPC 简易计算器面试题复盘

约 11 分钟阅读发布于 2025/4/11

这是我曾经做过的一道面试题中的一个小项目:用 Go + gRPC + gRPC-Web + Next.js 做一个简易计算器。

题目本身不复杂,只有加、减、乘、除四种运算。但它真正考察的不是“会不会写计算器”,而是能不能把前后端通信、接口契约、错误处理和跨端调用讲清楚。

项目地址:GolangNextGrpcSimpleCalculator

这道题可以理解为:

前端输入两个数字和一个运算符,通过 gRPC-Web 调用 Go 后端,后端完成计算并返回结果。

最小功能闭环包括:

  • 前端页面可以输入两个操作数。
  • 前端可以选择 +-*/ 四种运算符。
  • 前端通过 gRPC-Web 调用后端。
  • 后端使用 Go 实现 gRPC 服务。
  • 后端根据请求参数返回计算结果。
  • 除数为 0 或未知运算符时,需要返回错误。

如果只是做一个普通 HTTP 接口,这题可能很快就结束了。但这里要求使用 gRPC,就会多出一层“接口契约”的设计:前后端都要围绕 .proto 文件生成代码。

这个项目大致可以拆成两部分:

  • 文件夹calculator-backend/
    • 文件夹calculator/
      • calculator.proto
    • main.go
    • calculator_test.go
    • go.mod
  • 文件夹calculator-frontend/
    • 文件夹app/
      • 文件夹generated/
        • 文件夹calculator/
      • page.tsx
    • 文件夹calculator/
      • calculator.proto
    • package.json

后端负责定义和实现计算服务,前端负责生成 gRPC-Web 客户端代码并发起调用。

这里最核心的文件有三个:

文件作用
calculator.proto定义服务、请求结构、响应结构
main.go实现 Go gRPC 服务,并包装成 gRPC-Web HTTP 服务
page.tsx在 Next.js 页面中创建请求并调用后端

gRPC 的入口通常不是先写控制器,而是先写 .proto

calculator.proto
syntax = "proto3";
package calculator;
option go_package = "github.com/2760439882/calculator-backend/calculator;calculator";
service Calculator {
rpc Calculate(CalculationRequest) returns (CalculationResponse);
}
message CalculationRequest {
double operand1 = 1;
double operand2 = 2;
string operator = 3; // "+", "-", "*", "/"
}
message CalculationResponse {
double result = 1;
}

这份协议里有三个关键信息:

  • Calculator 是服务名。
  • Calculate 是远程调用方法。
  • CalculationRequestCalculationResponse 分别是请求和响应结构。

这就是 gRPC 和普通 REST 接口很不一样的地方。REST 接口经常先约定 URL 和 JSON 字段,而 gRPC 会先约定服务方法和强类型消息结构。

后端使用 Go 实现 Calculator 服务。

main.go
type server struct {
pb.UnimplementedCalculatorServer
}
func (s *server) Calculate(
ctx context.Context,
req *pb.CalculationRequest,
) (*pb.CalculationResponse, error) {
var result float64
switch req.Operator {
case "+":
result = req.Operand1 + req.Operand2
case "-":
result = req.Operand1 - req.Operand2
case "*":
result = req.Operand1 * req.Operand2
case "/":
if req.Operand2 == 0 {
return nil, fmt.Errorf("division by zero")
}
result = req.Operand1 / req.Operand2
default:
return nil, fmt.Errorf("unknown operator")
}
return &pb.CalculationResponse{Result: result}, nil
}

这段实现很适合面试时讲,因为它虽然简单,但覆盖了服务端接口实现的几个基本点:

  • 方法签名来自 .proto 生成代码。
  • 请求参数来自 CalculationRequest
  • 返回值必须符合 CalculationResponse
  • 错误可以通过 error 返回给调用方。
  • 除法需要额外处理除数为 0 的情况。

真正的业务逻辑只有一个 switch,但重点是:这个 switch 被放在了 gRPC 服务方法里,前端不会直接知道后端怎么计算,只知道自己要调用 Calculate

浏览器不能像 Go、Java、Node 服务端那样直接发起原生 gRPC 请求。原生 gRPC 基于 HTTP/2,而浏览器端直接使用 gRPC 会受到限制。

所以前端调用 gRPC 服务时,通常需要一层 gRPC-Web。

这个项目里,Go 后端把原始 gRPC 服务包装成了 gRPC-Web 服务:

main.go
grpcServer := grpc.NewServer()
pb.RegisterCalculatorServer(grpcServer, &server{})
wrappedGrpc := grpcweb.WrapServer(grpcServer)
httpServer := http.Server{
Addr: ":8080",
Handler: cors.New(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{
"Content-Type",
"X-Grpc-Web",
"X-User-Agent",
"grpc-timeout",
},
AllowCredentials: true,
}).Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if wrappedGrpc.IsGrpcWebRequest(r) ||
wrappedGrpc.IsAcceptableGrpcCorsRequest(r) ||
wrappedGrpc.IsGrpcWebSocketRequest(r) {
wrappedGrpc.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})),
}

这里有两个面试时值得说清楚的点。

第一,后端并不是直接写一个普通 HTTP JSON 接口,而是先创建 grpc.NewServer(),再通过 grpcweb.WrapServer() 包装。

第二,因为前端运行在 http://localhost:3000,后端运行在 http://localhost:8080,所以需要配置 CORS。否则浏览器会先把请求挡掉,根本到不了 gRPC-Web 服务。

前端是 Next.js 页面。因为要在浏览器里响应用户输入和发起请求,所以页面文件使用了 'use client'

page.tsx
'use client';
import { useState } from 'react';
import { CalculatorClient } from './generated/calculator/calculator_grpc_web_pb';
import { CalculateRequest } from './generated/calculator/calculator_pb';
const client = new CalculatorClient('http://localhost:8080', null, null);
export default function Home() {
const [operand1, setOperand1] = useState('');
const [operand2, setOperand2] = useState('');
const [operator, setOperator] = useState('+');
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleCalculate = () => {
setLoading(true);
setResult(null);
setError(null);
const req = new CalculateRequest();
req.setOperand1(parseFloat(operand1));
req.setOperand2(parseFloat(operand2));
req.setOperator(operator);
client.calculate(req, {}, (err, response) => {
setLoading(false);
if (err) {
setError('请求失败: ' + err.message);
return;
}
setResult(response.getResult());
});
};
}

这里的关键不是 useState,而是这三步:

  1. 使用生成的 CalculatorClient 创建客户端。
  2. 使用生成的 CalculateRequest 创建请求对象。
  3. 调用 client.calculate(),在回调里处理结果或错误。

这说明前端并没有手写请求路径、请求体字段和响应解析逻辑。它依赖 .proto 生成的代码来保证调用结构一致。

如果把这道题当成面试题看,它主要考察下面几类能力。

考点具体体现
gRPC 基础是否知道 .proto、service、message、生成代码
前后端通信是否知道浏览器不能直接使用原生 gRPC,需要 gRPC-Web
Go 服务端是否能注册服务、实现接口、启动服务
错误处理是否处理除数为 0、未知运算符、请求失败
跨域问题是否知道前后端端口不同会触发 CORS
工程意识是否能把生成代码、后端代码、前端代码分清楚

所以面试时不要只说“我实现了一个计算器”。更好的说法是:

我用 .proto 定义了计算服务和消息结构,再用 Go 实现 gRPC 服务。因为前端运行在浏览器里,不能直接调用原生 gRPC,所以后端用 gRPC-Web 包装了一层 HTTP 服务,并配置 CORS。前端通过生成的 gRPC-Web Client 创建请求对象,调用后端的 Calculate 方法,最后处理成功结果和错误信息。

这段回答会比“我写了加减乘除”更能说明你理解了题目。

这个项目作为面试题已经能跑通主流程,但如果继续完善,可以从下面几个方向补强。

第一,输入校验可以提前放在前端。

现在前端直接对输入值 parseFloat()。如果用户没有输入数字,可能得到 NaN。可以在发请求前判断两个操作数是否合法。

第二,后端错误可以使用 gRPC status。

当前代码使用 fmt.Errorf() 返回错误。实际项目里可以使用 status.Error() 搭配 codes.InvalidArgument,这样调用方可以更准确地区分错误类型。

return nil, status.Error(codes.InvalidArgument, "division by zero")

第三,运算符可以使用枚举。

当前 operator 是字符串,优点是直观,缺点是容易传入非法值。如果要更严谨,可以在 .proto 中定义 enum。

enum Operator {
OPERATOR_UNSPECIFIED = 0;
ADD = 1;
SUBTRACT = 2;
MULTIPLY = 3;
DIVIDE = 4;
}

第四,配置可以抽出来。

前端里的 http://localhost:8080 和后端 CORS 里的 http://localhost:3000 都是开发环境地址。后续如果部署,最好放进环境变量里。

这道题可以按四步讲:

  1. 先说题目目标:前端输入表达式,后端通过 gRPC 完成计算。
  2. 再说接口契约:用 .proto 定义 Calculate 方法、请求和响应。
  3. 接着说后端实现:Go 实现服务,处理四种运算和异常情况。
  4. 最后说浏览器调用:使用 gRPC-Web 生成客户端,前端发起请求并处理结果。

如果面试官继续追问,可以展开这几个点:

  • gRPC 和 REST 的区别是什么?
  • 为什么浏览器需要 gRPC-Web?
  • .proto 文件改了以后要做什么?
  • 如果除数为 0,应该怎么返回错误?
  • 如果以后要支持更多运算,应该怎么扩展?

这个项目的价值不在于计算器本身,而在于它用一个很小的功能,把 gRPC 项目里最关键的链路串起来了:

proto 定义 -> 生成代码 -> Go 实现服务 -> gRPC-Web 包装 -> 前端调用 -> 展示结果

对于面试题来说,这种项目很合适。它足够小,能在有限时间内完成;同时又能覆盖接口设计、后端实现、前端调用、跨域和错误处理这些真实工程里会遇到的问题。