V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
The Go Programming Language
http://golang.org/
Go Playground
Go Projects
Revel Web Framework
onanying
V2EX  ›  Go 编程语言

MixGo v1.1 发布, Go 快速开发脚手架工具

  •  2
     
  •   onanying · 2021-04-13 18:33:58 +08:00 · 2365 次点击
    这是一个创建于 1108 天前的主题,其中的信息可能已经有所发展或是发生改变。

    Mix Go 是一个基于 Go 进行快速开发的完整系统,类似前端的 Vue CLI,提供:

    • 通过 mix-go/mixcli 实现的交互式项目脚手架:
      • 可以生成 cli, api, web, grpc 多种项目代码
      • 生成的代码开箱即用
      • 可选择是否需要 .env 环境配置
      • 可选择是否需要 .yml, .json, .toml 等独立配置
      • 可选择使用 gorm, xorm 的数据库
      • 可选择使用 logrus, zap 的日志库
    • 通过 mix-go/xcli 实现的命令行原型开发。
    • 基于 mix-go/xdi 的 DI, IoC 容器。

    Github | Gitee

    快速开始

    安装

    go get github.com/mix-go/mixcli
    

    创建项目

    $ mixcli new hello
    Use the arrow keys to navigate: ↓ ↑ → ← 
    ? Select project type:
      ▸ CLI
        API
        Web (contains the websocket)
        gRPC
    

    技术交流

    知乎: https://www.zhihu.com/people/onanying
    微博: http://weibo.com/onanying
    官方 QQ 群:284806582, 825122875,敲门暗号:goer

    编写一个 CLI 程序

    首先我们使用 mixcli 命令创建一个项目骨架:

    $ mixcli new hello
    Use the arrow keys to navigate: ↓ ↑ → ← 
    ? Select project type:
      ▸ CLI
        API
        Web (contains the websocket)
        gRPC
    

    生成骨架目录结构如下:

    .
    ├── README.md
    ├── bin
    ├── commands
    ├── conf
    ├── configor
    ├── di
    ├── dotenv
    ├── go.mod
    ├── go.sum
    ├── logs
    └── main.go
    

    mian.go 文件:

    • xcli.AddCommand 方法传入的 commands.Commands 定义了全部的命令
    package main
    
    import (
      "github.com/mix-go/cli-skeleton/commands"
      _ "github.com/mix-go/cli-skeleton/configor"
      _ "github.com/mix-go/cli-skeleton/di"
      _ "github.com/mix-go/cli-skeleton/dotenv"
      "github.com/mix-go/dotenv"
      "github.com/mix-go/xcli"
    )
    
    func main() {
      xcli.SetName("app").
        SetVersion("0.0.0-alpha").
        SetDebug(dotenv.Getenv("APP_DEBUG").Bool(false))
      xcli.AddCommand(commands.Commands...).Run()
    }
    

    commands/main.go 文件:

    我们可以在这里自定义命令,查看更多

    • RunI 定义了 hello 命令执行的接口,也可以使用 Run 设定一个匿名函数
    package commands
    
    import (
      "github.com/mix-go/xcli"
    )
    
    var Commands = []*xcli.Command{
      {
        Name:  "hello",
        Short: "\tEcho demo",
        Options: []*xcli.Option{
          {
            Names: []string{"n", "name"},
            Usage: "Your name",
          },
          {
            Names: []string{"say"},
            Usage: "\tSay ...",
          },
        },
        RunI: &HelloCommand{},
      },
    }
    

    commands/hello.go 文件:

    业务代码写在 HelloCommand 结构体的 main 方法中

    • 代码中可以使用 flag 获取命令行参数,查看更多
    package commands
    
    import (
      "fmt"
      "github.com/mix-go/xcli/flag"
    )
    
    type HelloCommand struct {
    }
    
    func (t *HelloCommand) Main() {
      name := flag.Match("n", "name").String("OpenMix")
      say := flag.Match("say").String("Hello, World!")
      fmt.Printf("%s: %s\n", name, say)
    }
    

    接下来我们编译上面的程序:

    • linux & macOS
    go build -o bin/go_build_main_go main.go
    
    • win
    go build -o bin/go_build_main_go.exe main.go
    

    查看全部命令的帮助信息:

    $ cd bin
    $ ./go_build_main_go 
    Usage: ./go_build_main_go [OPTIONS] COMMAND [opt...]
    
    Global Options:
      -h, --help    Print usage
      -v, --version Print version information
    
    Commands:
      hello         Echo demo
    
    Run './go_build_main_go COMMAND --help' for more information on a command.
    
    Developed with Mix Go framework. (openmix.org/mix-go)
    

    查看上面编写的 hello 命令的帮助信息:

    $ ./go_build_main_go hello --help
    Usage: ./go_build_main_go hello [opt...]
    
    Command Options:
      -n, --name    Your name
      --say         Say ...
    
    Developed with Mix Go framework. (openmix.org/mix-go)
    

    执行 hello 命令,并传入两个参数:

    $ ./go_build_main_go hello --name=liujian --say=hello
    liujian: hello
    

    编写一个 Worker Pool 队列消费

    队列消费是高并发系统中最常用的异步处理模型,通常我们是编写一个 CLI 命令行程序在后台执行 Redis 、RabbitMQ 等 MQ 的队列消费,并将处理结果落地到 mysql 等数据库中,由于这类需求的标准化比较容易,因此我们开发了 mix-go/xwp 库来处理这类需求,基本上大部分异步处理类需求都可使用。

    新建 commands/workerpool.go 文件:

    • workerpool.NewDispatcher(jobQueue, 15, NewWorker) 创建了一个调度器
    • NewWorker 负责初始化执行任务的工作协程
    • 任务数据会在 worker.Do 方法中触发,我们只需要将我们的业务逻辑写到该方法中即可
    • 当程序接收到进程退出信号时,调度器能平滑控制所有的 Worker 在执行完队列里全部的任务后再退出调度,保证数据的完整性
    package commands
    
    import (
        "context"
        "fmt"
        "github.com/mix-go/cli-skeleton/di"
        "github.com/mix-go/xwp"
        "os"
        "os/signal"
        "strings"
        "syscall"
        "time"
    )
    
    type worker struct {
        xwp.WorkerTrait
    }
    
    func (t *worker) Do(data interface{}) {
        defer func() {
            if err := recover(); err != nil {
                logger := di.Logrus()
                logger.Error(err)
            }
        }()
    
        // 执行业务处理
        // ...
        
        // 将处理结果落地到数据库
        // ...
    }
    
    func NewWorker() xwp.Worker {
        return &worker{}
    }
    
    type WorkerPoolDaemonCommand struct {
    }
    
    func (t *WorkerPoolDaemonCommand) Main() {
        redis := globals.Redis()
        jobQueue := make(chan interface{}, 50)
        d := xwp.NewDispatcher(jobQueue, 15, NewWorker)
    
        ch := make(chan os.Signal)
        signal.Notify(ch, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
        go func() {
            <-ch
            d.Stop()
        }()
    
        go func() {
            for {
                res, err := redis.BRPop(context.Background(), 3*time.Second, "foo").Result()
                if err != nil {
                    if strings.Contains(err.Error(), "redis: nil") {
                        continue
                    }
                    fmt.Println(fmt.Sprintf("Redis Error: %s", err))
                    d.Stop();
                    return
                }
                // brPop 命令最后一个键才是值
                jobQueue <- res[1]
            }
        }()
    
        d.Run() // 阻塞代码,直到任务全部执行完成并且全部 Worker 停止
    }
    

    接下来只需要把这个命令通过 xcli.AddCommand 注册到 CLI 中即可。

    编写一个 API 服务

    首先我们使用 mixcli 命令创建一个项目骨架:

    $ mixcli new hello
    Use the arrow keys to navigate: ↓ ↑ → ← 
    ? Select project type:
        CLI
      ▸ API
        Web (contains the websocket)
        gRPC
    

    生成骨架目录结构如下:

    .
    ├── README.md
    ├── bin
    ├── commands
    ├── conf
    ├── configor
    ├── controllers
    ├── di
    ├── dotenv
    ├── go.mod
    ├── go.sum
    ├── main.go
    ├── middleware
    ├── routes
    └── runtime
    

    mian.go 文件:

    • xcli.AddCommand 方法传入的 commands.Commands 定义了全部的命令
    package main
    
    import (
      "github.com/mix-go/api-skeleton/commands"
      _ "github.com/mix-go/api-skeleton/configor"
      _ "github.com/mix-go/api-skeleton/di"
      _ "github.com/mix-go/api-skeleton/dotenv"
      "github.com/mix-go/dotenv"
      "github.com/mix-go/xcli"
    )
    
    func main() {
      xcli.SetName("app").
        SetVersion("0.0.0-alpha").
        SetDebug(dotenv.Getenv("APP_DEBUG").Bool(false))
      xcli.AddCommand(commands.Commands...).Run()
    }
    

    commands/main.go 文件:

    我们可以在这里自定义命令,查看更多

    • RunI 指定了命令执行的接口,也可以使用 Run 设定一个匿名函数
    package commands
    
    import (
      "github.com/mix-go/xcli"
    )
    
    var Commands = []*xcli.Command{
      {
        Name:  "api",
        Short: "\tStart the api server",
        Options: []*xcli.Option{
          {
            Names: []string{"a", "addr"},
            Usage: "\tListen to the specified address",
          },
          {
            Names: []string{"d", "daemon"},
            Usage: "\tRun in the background",
          },
        },
        RunI: &APICommand{},
      },
    }
    

    commands/api.go 文件:

    业务代码写在 APICommand 结构体的 main 方法中,生成的代码中已经包含了:

    • 监听信号停止服务
    • 根据模式打印日志
    • 可选的后台守护执行

    基本上无需修改即可上线使用

    package commands
    
    import (
      "context"
      "fmt"
      "github.com/gin-gonic/gin"
      "github.com/mix-go/api-skeleton/di"
      "github.com/mix-go/api-skeleton/routes"
      "github.com/mix-go/dotenv"
      "github.com/mix-go/xcli/flag"
      "github.com/mix-go/xcli/process"
      "os"
      "os/signal"
      "strings"
      "syscall"
      "time"
    )
    
    type APICommand struct {
    }
    
    func (t *APICommand) Main() {
      if flag.Match("d", "daemon").Bool() {
        process.Daemon()
      }
    
      logger := di.Logrus()
      server := di.Server()
      addr := dotenv.Getenv("GIN_ADDR").String(":8080")
      mode := dotenv.Getenv("GIN_MODE").String(gin.ReleaseMode)
    
      // server
      gin.SetMode(mode)
      router := gin.New()
      routes.SetRoutes(router)
      server.Addr = flag.Match("a", "addr").String(addr)
      server.Handler = router
    
      // signal
      ch := make(chan os.Signal)
      signal.Notify(ch, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
      go func() {
        <-ch
        logger.Info("Server shutdown")
        ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)
        if err := server.Shutdown(ctx); err != nil {
          logger.Errorf("Server shutdown error: %s", err)
        }
      }()
    
      // logger
      if mode != gin.ReleaseMode {
        handlerFunc := gin.LoggerWithConfig(gin.LoggerConfig{
          Formatter: func(params gin.LogFormatterParams) string {
            return fmt.Sprintf("%s|%s|%d|%s",
              params.Method,
              params.Path,
              params.StatusCode,
              params.ClientIP,
            )
          },
          Output: logger.Out,
        })
        router.Use(handlerFunc)
      }
    
      // run
      welcome()
      logger.Infof("Server start at %s", server.Addr)
      if err := server.ListenAndServe(); err != nil && !strings.Contains(err.Error(), "http: Server closed") {
        panic(err)
      }
    }
    

    routes/main.go 文件中配置路由:

    已经包含一些常用实例,只需要在这里新增路由即可开始开发

    package routes
    
    import (
      "github.com/gin-gonic/gin"
      "github.com/mix-go/api-skeleton/controllers"
      "github.com/mix-go/api-skeleton/middleware"
    )
    
    func SetRoutes(router *gin.Engine) {
      router.Use(gin.Recovery()) // error handle
    
      router.GET("hello",
        middleware.CorsMiddleware(),
        func(ctx *gin.Context) {
          hello := controllers.HelloController{}
          hello.Index(ctx)
        },
      )
    
      router.POST("users/add",
        middleware.AuthMiddleware(),
        func(ctx *gin.Context) {
          hello := controllers.UserController{}
          hello.Add(ctx)
        },
      )
    
      router.POST("auth", func(ctx *gin.Context) {
        auth := controllers.AuthController{}
        auth.Index(ctx)
      })
    }
    

    接下来我们编译上面的程序:

    • linux & macOS
    go build -o bin/go_build_main_go main.go
    
    • win
    go build -o bin/go_build_main_go.exe main.go
    

    启动服务器

    $ bin/go_build_main_go api
                 ___         
     ______ ___  _ /__ ___ _____ ______ 
      / __ `__ \/ /\ \/ /__  __ `/  __ \
     / / / / / / / /\ \/ _  /_/ // /_/ /
    /_/ /_/ /_/_/ /_/\_\  \__, / \____/ 
                         /____/
    
    
    Server      Name:      mix-api
    Listen      Addr:      :8080
    System      Name:      darwin
    Go          Version:   1.13.4
    Framework   Version:   1.0.9
    time=2020-09-16 20:24:41.515 level=info msg=Server start file=api.go:58
    

    编写一个 Web 服务

    内容放不下,省略...

    编写一个 gRPC 服务、客户端

    首先我们使用 mixcli 命令创建一个项目骨架:

    $ mixcli new hello
    Use the arrow keys to navigate: ↓ ↑ → ← 
    ? Select project type:
        CLI
        API
        Web (contains the websocket)
      ▸ gRPC
    

    生成骨架目录结构如下:

    .
    ├── README.md
    ├── bin
    ├── commands
    ├── conf
    ├── configor
    ├── di
    ├── dotenv
    ├── go.mod
    ├── go.sum
    ├── main.go
    ├── protos
    ├── runtime
    └── services
    

    mian.go 文件:

    • xcli.AddCommand 方法传入的 commands.Commands 定义了全部的命令
    package main
    
    import (
      "github.com/mix-go/dotenv"
      "github.com/mix-go/grpc-skeleton/commands"
      _ "github.com/mix-go/grpc-skeleton/configor"
      _ "github.com/mix-go/grpc-skeleton/di"
      _ "github.com/mix-go/grpc-skeleton/dotenv"
      "github.com/mix-go/xcli"
    )
    
    func main() {
      xcli.SetName("app").
        SetVersion("0.0.0-alpha").
        SetDebug(dotenv.Getenv("APP_DEBUG").Bool(false))
      xcli.AddCommand(commands.Commands...).Run()
    }
    

    commands/main.go 文件:

    我们可以在这里自定义命令,查看更多

    • 定义了 grpc:servergrpc:client 两个子命令
    • RunI 指定了命令执行的接口,也可以使用 Run 设定一个匿名函数
    package commands
    
    import (
      "github.com/mix-go/xcli"
    )
    
    var Commands = []*xcli.Command{
      {
        Name:  "grpc:server",
        Short: "gRPC server demo",
        Options: []*xcli.Option{
          {
            Names: []string{"d", "daemon"},
            Usage: "Run in the background",
          },
        },
        RunI: &GrpcServerCommand{},
      },
      {
        Name:  "grpc:client",
        Short: "gRPC client demo",
        RunI:  &GrpcClientCommand{},
      },
    }
    

    protos/user.proto 数据结构文件:

    客户端与服务器端代码中都需要使用 .proto 生成的 go 代码,因为双方需要使用该数据结构通讯

    • .protogRPC 通信的数据结构文件,采用 protobuf 协议
    syntax = "proto3";
    
    package go.micro.grpc.user;
    option go_package = ".;protos";
    
    service User {
        rpc Add(AddRequest) returns (AddResponse) {}
    }
    
    message AddRequest {
        string Name = 1;
    }
    
    message AddResponse {
        int32 error_code = 1;
        string error_message = 2;
        int64 user_id = 3;
    }
    

    然后我们需要安装 gRPC 相关的编译程序:

    接下来我们开始编译 .proto 文件:

    • 编译成功后会在当前目录生成 protos/user.pb.go 文件
    cd protos
    protoc --go_out=plugins=grpc:. user.proto
    

    commands/server.go 文件:

    服务端代码写在 GrpcServerCommand 结构体的 main 方法中,生成的代码中已经包含了:

    • 监听信号停止服务
    • 可选的后台守护执行
    • pb.RegisterUserServer 注册了一个默认服务,用户只需要扩展自己的服务即可
    package commands
    
    import (
      "github.com/mix-go/dotenv"
      "github.com/mix-go/grpc-skeleton/di"
      pb "github.com/mix-go/grpc-skeleton/protos"
      "github.com/mix-go/grpc-skeleton/services"
      "github.com/mix-go/xcli/flag"
      "github.com/mix-go/xcli/process"
      "google.golang.org/grpc"
      "net"
      "os"
      "os/signal"
      "strings"
      "syscall"
    )
    
    var listener net.Listener
    
    type GrpcServerCommand struct {
    }
    
    func (t *GrpcServerCommand) Main() {
      if flag.Match("d", "daemon").Bool() {
        process.Daemon()
      }
    
      addr := dotenv.Getenv("GIN_ADDR").String(":8080")
      logger := di.Logrus()
    
      // listen
      listener, err := net.Listen("tcp", addr)
      if err != nil {
        panic(err)
      }
      listener = listener
    
      // signal
      ch := make(chan os.Signal)
      signal.Notify(ch, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM)
      go func() {
        <-ch
        logger.Info("Server shutdown")
        if err := listener.Close(); err != nil {
          panic(err)
        }
      }()
    
      // server
      s := grpc.NewServer()
      pb.RegisterUserServer(s, &services.UserService{})
    
      // run
      welcome()
      logger.Infof("Server run %s", addr)
      if err := s.Serve(listener); err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
        panic(err)
      }
    }
    

    services/user.go 文件:

    服务端代码中注册的 services.UserService{} 服务代码如下:

    只需要填充业务逻辑即可

    package services
    
    import (
      "context"
      pb "github.com/mix-go/grpc-skeleton/protos"
    )
    
    type UserService struct {
    }
    
    func (t *UserService) Add(ctx context.Context, in *pb.AddRequest) (*pb.AddResponse, error) {
      // 执行数据库操作
      // ...
    
      resp := pb.AddResponse{
        ErrorCode:    0,
        ErrorMessage: "",
        UserId:       10001,
      }
      return &resp, nil
    }
    

    commands/client.go 文件:

    客户端代码写在 GrpcClientCommand 结构体的 main 方法中,生成的代码中已经包含了:

    • 通过环境配置获取服务端连接地址
    • 设定了 5s 的执行超时时间
    package commands
    
    import (
        "context"
        "fmt"
      "github.com/mix-go/dotenv"
      pb "github.com/mix-go/grpc-skeleton/protos"
        "google.golang.org/grpc"
        "time"
    )
    
    type GrpcClientCommand struct {
    }
    
    func (t *GrpcClientCommand) Main() {
      addr := dotenv.Getenv("GIN_ADDR").String(":8080")
        ctx, _ := context.WithTimeout(context.Background(), time.Duration(5)*time.Second)
        conn, err := grpc.DialContext(ctx, addr, grpc.WithInsecure(), grpc.WithBlock())
        if err != nil {
            panic(err)
        }
        defer func() {
            _ = conn.Close()
        }()
        cli := pb.NewUserClient(conn)
        req := pb.AddRequest{
            Name: "xiaoliu",
        }
        resp, err := cli.Add(ctx, &req)
        if err != nil {
            panic(err)
        }
        fmt.Println(fmt.Sprintf("Add User: %d", resp.UserId))
    }
    

    接下来我们编译上面的程序:

    • linux & macOS
    go build -o bin/go_build_main_go main.go
    
    • win
    go build -o bin/go_build_main_go.exe main.go
    

    首先在命令行启动 grpc:server 服务器:

    $ bin/go_build_main_go grpc:server
                 ___         
     ______ ___  _ /__ ___ _____ ______ 
      / __ `__ \/ /\ \/ /__  __ `/  __ \
     / / / / / / / /\ \/ _  /_/ // /_/ /
    /_/ /_/ /_/_/ /_/\_\  \__, / \____/ 
                         /____/
    
    
    Server      Name:      mix-grpc
    Listen      Addr:      :8080
    System      Name:      darwin
    Go          Version:   1.13.4
    Framework   Version:   1.0.20
    time=2020-11-09 15:08:17.544 level=info msg=Server run :8080 file=server.go:46
    

    然后开启一个新的终端,执行下面的客户端命令与上面的服务器通信

    $ bin/go_build_main_go grpc:client
    Add User: 10001
    

    如何使用 DI 容器中的 Logger 、Database 、Redis 等组件

    项目中要使用的公共组件,都定义在 di 目录,框架默认生成了一些常用的组件,用户也可以定义自己的组件,查看更多

    • 可以在哪里使用

    可以在代码的任意位置使用,但是为了可以使用到环境变量和自定义配置,通常我们在 xcli.Command 结构体定义的 RunRunI 中使用。

    • 使用日志,比如:logruszap
    logger := di.Logrus()
    logger.Info("test")
    
    • 使用数据库,比如:gormxorm
    db := di.Gorm()
    user := User{Name: "Jinzhu", Age: 18, Birthday: time.Now()}
    result := db.Create(&user)
    fmt.Println(result)
    
    • 使用 Redis,比如:go-redis
    rdb := di.GoRedis()
    val, err := rdb.Get(context.Background(), "key").Result()
    if err != nil {
        panic(err)
    }
    fmt.Println("key", val)
    

    依赖

    官方库

    第三方库

    License

    Apache License Version 2.0, http://www.apache.org/licenses/

    12 条回复    2021-04-16 13:27:27 +08:00
    cexll
        1
    cexll  
       2021-04-13 18:38:55 +08:00
    就这?
    RyanTaro
        2
    RyanTaro  
       2021-04-13 18:43:05 +08:00
    这框架也好意思发出来开源?
    CEBBCAT
        3
    CEBBCAT  
       2021-04-13 18:57:50 +08:00 via Android
    @cexll
    @RyanTaro

    不太明白,是发生了什么吗?
    SWYou
        4
    SWYou  
       2021-04-13 19:00:03 +08:00 via iPhone
    卧槽,这楼上不懂得给点鼓励吗?
    linvon
        5
    linvon  
       2021-04-13 19:04:11 +08:00
    @CEBBCAT #3
    @SWYou #4
    看一楼还是项目的 contributer,估计是自己人吧
    TangMonk
        6
    TangMonk  
       2021-04-13 19:04:12 +08:00 via iPhone
    挺好的,赞一个
    keer
        7
    keer  
       2021-04-13 19:07:40 +08:00
    楼主的 MixPHP 也很牛逼呀。
    airplayxcom
        8
    airplayxcom  
       2021-04-13 21:48:42 +08:00
    都用 gin 、gorm 了, 为啥还要做胶水。
    zh5e
        9
    zh5e  
       2021-04-14 09:45:17 +08:00
    挺好的,赞一个
    CheatEngine
        10
    CheatEngine  
       2021-04-15 06:37:49 +08:00
    生成出来的搅屎棍代码,真的,有这个时间不如拿 go 造个轮子,而不是造毫无技术含量的代码生成器.
    这种代码哪有简洁性可言? "任何以代码生成器生成的侵入业务的外部代码都是垃圾".如果不明白我这句话,可以看看隔壁的 beego 的注解路由.注解只是开发,运行的时候使用自动生成的代码.不入侵业务,因为 controller 里只有一个注解.
    onanying
        11
    onanying  
    OP
       2021-04-16 13:19:53 +08:00
    @CheatEngine 你会用一个别人造的轮子,然后你哪里来的优越感?谁给你的勇气?
    onanying
        12
    onanying  
    OP
       2021-04-16 13:27:27 +08:00
    生成的代码里使用了这些轮子,反正你也是看不到的,只看到了代码生成器。

    https://github.com/mix-go/xcli
    https://github.com/mix-go/xdi
    https://github.com/mix-go/xwp
    https://github.com/mix-go/xfmt
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   3253 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 31ms · UTC 12:44 · PVG 20:44 · LAX 05:44 · JFK 08:44
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.